Skip to content

feat(mocker): model prefill→decode KV stranding + abort for disagg cascade tests - #10557

Closed
nnshah1 wants to merge 15 commits into
mainfrom
neelays/mocker-dis2147-stranding
Closed

feat(mocker): model prefill→decode KV stranding + abort for disagg cascade tests#10557
nnshah1 wants to merge 15 commits into
mainfrom
neelays/mocker-dis2147-stranding

Conversation

@nnshah1

@nnshah1 nnshah1 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Models disaggregated prefill→decode KV-block stranding in the mocker the way real vLLM does it — via the engine-opaque disaggregated_params (NIXL kv_transfer_params) — so cascade/overload tests reproduce the failure mode where a prefill worker holds KV for a decode that arrives late (or never), stranding the blocks until the transfer completes or times out.

The vLLM path is additive and engine-gated: sglang keeps its bootstrap-triple disaggregated_params format and behavior unchanged, and the router is not modified — only which of its existing branches is taken.

How it works

Stranding (scheduler core). A vLLM prefill runs, pins its KV on completion (the ActiveSequence and its block handles are retained), and emits disaggregated_params = {transfer_id, prefill_host, prefill_port}. Pinned KV still counts toward pool capacity (the cascade) but is discounted from the router-facing load metric, so a strand doesn't mis-divert overlap/load routing of unrelated requests. Release is event-driven and load-dependent (the strand lasts until that request's decode is admitted and pulls): live → the decode connects to the prefill by transfer_id over the cross-process channel and the pull fires the pin release; replay → an in-process transfer_id → pinned-uuid correlator releases when the matching decode is scheduled.

Bootstrap-free live engagement (mocker-only). A vLLM disagg prefill registers set_disaggregated_endpoint(None, None) — it advertises as a disagg/prefill worker with no bootstrap host/port — so the unchanged prefill_router takes its existing NoBootstrapEndpoint → output-disaggregated_params branch (execution.rs). The KV-transfer (channel) server starts for a vLLM prefill on an OS-assigned port even without --bootstrap-ports; the real port is emitted in disaggregated_params and the decode reads it. sglang always sets bootstrap_port, so it never reaches this branch — its behavior is byte-for-byte unchanged.

Scheduler liveness fix. The live scheduler loop blocks when there is no runnable work (!has_runnable_work()), not when it is fully empty (is_empty(), which counts pinned KV). Previously a pinned-only worker hot-spun — never blocking while holding a pin — which starved the decode workers whose admission releases the pin, compounding the prefill→decode handoff latency. A pinned-only worker can only advance via a request_rx message (a new request or a release_pin), so blocking on recv() is correct and deadlock-free.

Abort / reclaim. register_pin always installs the reclaim timer — an explicit kv_transfer_abort_timeout_ms when set, else RENDEZVOUS_TIMEOUT — for parity with sglang's wait_for_decode_ready, so a never-pulled pin is always reclaimed (and a clean ABORT_BYTE goes to waiting/late decodes). The timer's AbortHandle is stored and cancelled when a decode-connect releases the pin, so the happy path leaves no lingering task. Decode-side admission gates on both the block budget and the sequence-slot budget (max_num_seqs), matching real vLLM/sglang; block-only gating made the abort path unreachable under seq-saturation.

Forensic logging at target: "mocker::kv_abort"prefill_kv_pin_start, decode_kv_wait_start, prefill_kv_pin_end {outcome, duration_ms} — reconstructs the stranding graph (which prefill held KV for which decode, how long, completed vs aborted).

Channel key / naming. The cross-process channel uses a single engine-neutral u64 key, transfer_id. Type aliases RoomId and TransferId (both = u64) let each engine read in its native vocabulary (sglang bootstrap room vs vLLM kv_transfer_params transfer id) with no conversion — the same value hits the same map entry.

Changed files

File Change
lib/llm/src/mocker.rs Engine-gated disagg block. vLLM: prefill pins + emits real disaggregated_params; decode parses it → live channel connect by transfer_id. Starts the KV-transfer server for a vLLM prefill on an OS-assigned port (no --bootstrap-ports). sglang: existing bootstrap-room path, unchanged.
components/src/dynamo/mocker/config.py, main.py A vLLM disagg prefill advertises set_disaggregated_endpoint(None, None) (disagg, no bootstrap host/port) → the unchanged prefill_router takes NoBootstrapEndpoint. sglang keeps host/port.
lib/mocker/src/services/bootstrap.rs Generalize the channel key to transfer_id + RoomId/TransferId aliases; pin registry with always-on reclaim timer (parity default RENDEZVOUS_TIMEOUT) whose AbortHandle is cancelled on decode-connect release. connect/wait/ACK/ABORT machinery unchanged.
lib/mocker/src/scheduler/vllm/{core.rs,live.rs} Pin/release core (pin_completed/release_pinned/take_pinned); pinned footprint discounted from the router metric. Live loop blocks on no-runnable-work (not no-work) so a pinned-only worker parks instead of hot-spinning.
lib/mocker/src/replay/offline/* Time-based release removed; in-process transfer_id → pinned-uuid correlator releases when the matching decode is scheduled.
lib/bindings/python/rust/llm/replay.rs, _core.pyi kv_transfer_abort_timeout_ms param + stub.

Edge cases

  • Decode no-show (live): the reclaim timer fires → release pin + ABORT; waiting/late decodes get a clean abort byte instead of hanging.
  • Decode never appears (replay): release on teardown; unmatched pins are logged (a silent leak would skew occupancy).
  • Prefill cancelled mid-strand: released on the cancellation path.
  • Pool full while pinned = the cascade (intended): pinned KV stays in num_active_blocks → new prefills are rejected/queued; the router-metric discount keeps stranding from mis-diverting overlap routing.
  • Duplicate/late release: release_pinned(uuid) is idempotent; the channel release fires at-most-once.
  • vLLM request with no disaggregated_params (aggregated): no pin, normal free.

Non-goals

  • Replacing or altering the sglang disagg path or its bootstrap format.
  • Real NIXL agent-metadata fidelity (engine_id, block-id lists) — the mocker models the timing/holding semantics, not the wire format.
  • Removing the cross-process channel for live (inherent to multi-process disagg).

Test

cargo fmt / cargo clippy clean on dynamo-mocker + dynamo-llm; cargo test -p dynamo-mocker --lib = 366 passed (incl. pin/release/abort channel tests, replay event-driven release, and JSON round-trip). Live multi-process e2e (tests/router/test_router_e2e_with_mockers.py) validates the bootstrap-free disagg_params path (no_bootstrap), the unchanged bootstrap path (with_bootstrap), and background-prefill stickiness — green under load, with flat pin durations (no handoff-latency compounding). A new tests/router/test_ll_strand_check.py exercises the full path under Least Loaded frontend routing (vLLM disagg, no --bootstrap-ports) end-to-end. The pinned-prefill liveness and abort/no-decode concurrency paths were reviewed for locks-across-await, missed wakeups, and deadlock.

🤖 Generated with Claude Code


Open in Devin Review

@nnshah1
nnshah1 requested review from a team as code owners June 10, 2026 20:30
@nnshah1
nnshah1 requested a review from a team June 10, 2026 20:30
@github-actions github-actions Bot added the feat label Jun 10, 2026

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 7 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread components/src/dynamo/mocker/args.py Outdated
Comment thread lib/mocker/src/common/protocols.rs
Comment thread lib/mocker/src/services/bootstrap.rs Outdated
Comment thread lib/llm/src/mocker.rs Outdated
Comment thread lib/mocker/src/scheduler/vllm/live.rs
Comment thread lib/mocker/src/scheduler/sglang/config.rs
Comment thread lib/bindings/python/rust/llm/replay.rs
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR implements DIS-2147: an optional abort timeout for disaggregated prefill-to-decode KV cache transfers. A new --kv-transfer-abort-timeout-ms parameter controls whether prefill waits for decode and aborts if it doesn't arrive in time. The bootstrap rendezvous protocol is refactored to support explicit ACK/ABORT byte responses instead of immediate ACK. Decode conditionally waits for local KV capacity before connecting. Metrics now track sequence slot capacity.

Changes

DIS-2147 Disaggregated Serving Abort Timeout

Layer / File(s) Summary
Configuration threading: CLI → Python → Rust
components/src/dynamo/mocker/args.py, components/src/dynamo/mocker/config.py, lib/bindings/python/rust/llm/replay.rs
New --kv-transfer-abort-timeout-ms CLI argument is introduced in Python argparse and threaded through the config builder into Rust bindings and MockEngineArgs constructor.
Engine protocol schema: MockEngineArgs with abort timeout
lib/mocker/src/common/protocols.rs
MockEngineArgs struct and serde layer extended with new optional kv_transfer_abort_timeout_ms field; updated in deserialization and JSON round-trip tests.
Bootstrap protocol refactor: ACK/ABORT handshake with state machine
lib/mocker/src/services/bootstrap.rs
Bootstrap rendezvous refactored from simple readiness flag to RoomOutcome state machine (Pending/Completed/Aborted). Decode registration and prefill waiting now support explicit ACK/ABORT byte responses. New abort_room method, timeout behavior retains rooms for late ABORT delivery, and DIS-2147 scenario tests added.
MockEngine KV capacity gating and prefill abort handling
lib/llm/src/mocker.rs
Decode-side conditionally waits for local KV capacity (block and sequence headroom) before connecting. Prefill waits for decode ready with abort timeout; on timeout, aborts room and sends error to client. Adds forensic logging for KV wait start and prefill KV pin duration.
Sequence metrics: max_num_seqs field and scheduler propagation
lib/mocker/src/scheduler/vllm/live.rs, lib/mocker/src/scheduler/sglang/config.rs, lib/mocker/src/scheduler/sglang/core.rs, lib/mocker/src/scheduler/vllm/core.rs
MockerMetrics gains max_num_seqs field to track sequence slot capacity. Scheduler configs derive this from MockEngineArgs and populate it in metrics during execute passes.

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(mocker): model prefill→decode KV stranding + abort for disagg cascade tests' clearly summarizes the main change—adding KV stranding and abort behavior to the mocker's disaggregated prefill-decode model.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes an overview of the changes, detailed explanation of how the feature works, a list of changed files, edge cases, non-goals, and test coverage information.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
lib/mocker/src/scheduler/vllm/core.rs (1)

510-525: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace Linear ticket reference with GitHub issue number.

Comment references "DIS-2147". Replace with the corresponding GitHub issue number (e.g., GH-NNNN or #NNNN) per coding guidelines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/mocker/src/scheduler/vllm/core.rs` around lines 510 - 525, The inline
comment in function mocker_metrics() references a Linear ticket "DIS-2147";
update that comment to use the project's GitHub issue format (e.g.,
"GH-<number>" or "#<number>") per guidelines by replacing "DIS-2147" with the
correct GitHub issue identifier in the comment above metrics.max_num_seqs so the
comment reads e.g. "// GH-XXXX: expose the seq-slot cap..." instead of the
Linear ticket; ensure only the comment text is changed and code/logic
(mocker_metrics, metrics.max_num_seqs) remains untouched.

Source: Coding guidelines

lib/mocker/src/services/bootstrap.rs (1)

190-216: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The decode side still times out independently after 30s.

Both the server-side wait and the client-side response read are still capped by RENDEZVOUS_TIMEOUT. If kv_transfer_abort_timeout_ms is larger than 30s, or prefill legitimately finishes later than that, decode fails early and handle_connection() removes the room before prefill can complete/abort.

⏱️ Suggested direction
-            ImmediateOrWait::Wait(rx) => match tokio::time::timeout(RENDEZVOUS_TIMEOUT, rx).await {
-                Ok(Ok(RoomOutcome::Completed)) => {
+            ImmediateOrWait::Wait(rx) => match rx.await {
+                Ok(RoomOutcome::Completed) => {
                     tracing::debug!("Bootstrap: room {room_id} prefill completed, sending ACK");
                     ACK_BYTE
                 }
-                Ok(Ok(RoomOutcome::Aborted)) => {
+                Ok(RoomOutcome::Aborted) => {
                     tracing::warn!(
                         "Bootstrap: room {room_id} prefill aborted while decode waited, \
                              sending ABORT"
                     );
                     ABORT_BYTE
                 }
-                Ok(Ok(RoomOutcome::Pending)) => {
+                Ok(RoomOutcome::Pending) => {
                     bail!("Bootstrap: room {room_id} sender fired with Pending outcome");
                 }
-                Ok(Err(_)) => {
+                Err(_) => {
                     bail!("Bootstrap: room {room_id} sender dropped");
                 }
-                Err(_) => {
-                    rooms.remove(&room_id);
-                    bail!("Bootstrap: room {room_id} timeout waiting for prefill");
-                }
             },
@@
-    tokio::time::timeout(RENDEZVOUS_TIMEOUT, stream.read_exact(&mut response))
-        .await
-        .map_err(|_| anyhow::anyhow!("Bootstrap: response timeout for room {room_id}"))?
-        .map_err(|e| anyhow::anyhow!("Bootstrap: read response failed: {e}"))?;
+    stream
+        .read_exact(&mut response)
+        .await
+        .map_err(|e| anyhow::anyhow!("Bootstrap: read response failed: {e}"))?;

Based on supplied downstream context, prefill-side abort_timeout is intended to be authoritative, so these fixed 30s decode-side waits can now fail first.

Also applies to: 365-370

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/mocker/src/services/bootstrap.rs` around lines 190 - 216, The decode-side
wait uses the global RENDEZVOUS_TIMEOUT, causing premature timeouts; change the
tokio::time::timeout calls (e.g. where ImmediateOrWait::Wait(rx) matches) to use
the room-specific abort timeout (the prefill/kv_transfer_abort_timeout_ms value)
instead of RENDEZVOUS_TIMEOUT, obtaining that timeout from the Room or prefill
params available in bootstrap.rs/handle_connection() and applying the same
change to the other occurrences around lines 365-370; ensure you pass the
correctly converted Duration to tokio::time::timeout and only remove the room
from rooms.remove(&room_id) if the room-specific timeout elapses.
lib/bindings/python/rust/llm/replay.rs (1)

174-209: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Update the Python typing stub for the new constructor keyword.

This runtime signature now accepts kv_transfer_abort_timeout_ms, but the supplied _core.pyi stub still stops at kv_transfer_bandwidth. Typed callers will get a bogus unexpected-keyword error until the stub is updated too.

🧩 Follow-up stub change
--- a/lib/bindings/python/src/dynamo/_core.pyi
+++ b/lib/bindings/python/src/dynamo/_core.pyi
@@
         kv_bytes_per_token: Optional[int] = None,
         kv_transfer_bandwidth: Optional[float] = None,
+        kv_transfer_abort_timeout_ms: Optional[int] = None,
         reasoning: Optional[ReasoningConfig] = None,

Based on supplied typing stub context, lib/bindings/python/src/dynamo/_core.pyi has not been updated alongside this runtime signature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/bindings/python/rust/llm/replay.rs` around lines 174 - 209, The Python
typing stub for the constructor is out of sync: the Rust/pyO3 constructor
function new now accepts the kv_transfer_abort_timeout_ms keyword but the typed
stub (_core.pyi) still ends at kv_transfer_bandwidth; update the stub to add
kv_transfer_abort_timeout_ms: Optional[int] (or appropriate integer type) to the
constructor signature so typed callers no longer get unexpected-keyword errors,
ensuring the parameter name and type match the runtime signature of new and any
related overloads in the same stub.
🧹 Nitpick comments (2)
lib/llm/src/mocker.rs (1)

684-686: ⚡ Quick win

Use lazy formatting in tracing::warn!.

The warning message uses eager string interpolation. Prefer lazy formatting with structured fields or positional arguments to avoid unnecessary allocations when the log level is disabled.

Suggested refactor
-                            tracing::warn!(
-                                "Prefill aborting transfer for room {room_id}: {e}"
-                            );
+                            tracing::warn!(
+                                room_id = room_id,
+                                error = %e,
+                                "Prefill aborting transfer"
+                            );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/llm/src/mocker.rs` around lines 684 - 686, The tracing::warn! call is
doing eager string interpolation for room_id and e; replace it with lazy
formatting by passing room_id and e as structured fields or positional arguments
to tracing::warn! instead of embedding them in the format string—locate the
tracing::warn! invocation that references room_id and e in mocker.rs (the
prefill aborting transfer log) and change it to use structured fields like
room_id = %room_id and error = %e or use positional placeholders so the values
are only formatted when the warn level is enabled.
lib/mocker/src/common/protocols.rs (1)

1251-1260: ⚡ Quick win

Make the round-trip test use a concrete timeout value.

This still serializes kv_transfer_abort_timeout_ms as None, so the test won't fail if the new field stops round-tripping. Set a non-default value and assert it after restore.

🧪 Suggested test tightening
         let args = MockEngineArgs::builder()
             .worker_type(WorkerType::Decode)
+            .kv_transfer_abort_timeout_ms(Some(123))
             .max_num_seqs(None)
             .max_num_batched_tokens(None)
             .reasoning(None)
             .sglang(None)
             .build()
@@
         let restored = MockEngineArgs::from_json_str(&payload.to_string()).unwrap();
 
         assert_eq!(restored.worker_type, WorkerType::Decode);
+        assert_eq!(restored.kv_transfer_abort_timeout_ms, Some(123));
         assert_eq!(restored.max_num_seqs, None);
         assert_eq!(restored.max_num_batched_tokens, None);

Also applies to: 1295-1307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/mocker/src/common/protocols.rs` around lines 1251 - 1260, The round-trip
test builds MockEngineArgs without setting kv_transfer_abort_timeout_ms so it
remains None and won't catch serialization regressions; update the
MockEngineArgs::builder() call used in the round-trip test (the builder chain
that sets worker_type, max_num_seqs, max_num_batched_tokens, reasoning, sglang,
etc.) to explicitly set kv_transfer_abort_timeout_ms to a concrete non-default
value (e.g., Some(1234)), then after restore/assertion verify the restored
args.kv_transfer_abort_timeout_ms equals that value; apply the same change to
the other builder usage in the file that mirrors this test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/src/dynamo/mocker/args.py`:
- Around line 551-558: The help text string in
components/src/dynamo/mocker/args.py contains an internal ticket ID "DIS-2147";
update the help for the prefill-to-decode handshake timeout (the argument help
string around the prefill worker timeout) to remove "DIS-2147" and replace it
with either a public reference (e.g., "GH-<number>" or "#<number>") or plain
prose like "see public issue tracker" or simply omit the ticket reference,
preserving the existing explanatory text about behavior, defaults, and
decode-side requirements; edit the help string used when defining the timeout
argument so no internal Linear ID remains.

In `@lib/llm/src/mocker.rs`:
- Around line 600-607: The comment references an internal Linear ticket ID
"DIS-2147"; update that comment to reference the public GitHub issue format
(e.g., "GH-XXXX" or "`#XXXX`") per guidelines. Locate the comment block that
mentions DIS-2147 immediately above the abort_timeout check in the
function/method containing abort_timeout, and replace "DIS-2147" with the
correct GitHub issue number while preserving the rest of the comment and the
surrounding logic that calls self.wait_for_decode_kv_capacity(dp_rank).await and
maps errors with Error::msg.
- Around line 667-677: Update the hardcoded DIS-2147 reference in the comment
block and the tracing target to the project-standard GitHub issue format (e.g.,
"GH-NNNN" or "`#NNNN`"); specifically edit the comment starting with "// DIS-2147
forensic logging..." and the tracing::info! call target "mocker::dis2147" (and
any inline string "prefill_kv_pin_start" context if it embeds the ticket) to use
the GitHub issue identifier instead (e.g., replace DIS-2147 with GH-XXXX or
`#XXXX`) so both the comment and the tracing target reflect the new issue
numbering.
- Around line 775-777: Update the inline comment referencing "DIS-2147" to use
the GitHub issue format (e.g., "GH-NNNN" or "`#NNNN`") per guidelines: locate the
comment near the wait_for_decode_ready reference in mocker.rs (the block
mentioning "DIS-2147" and "KV stayed pinned") and replace "DIS-2147" with the
appropriate GitHub issue identifier (e.g., "GH-<number>" or "#<number>") while
keeping the rest of the comment text unchanged.
- Around line 679-709: The inline ticket reference "DIS-2147" in the comment and
tracing targets should be replaced with the repository issue format (e.g.,
"GH-XXXX" or "`#XXXX`"); update the top comment above the wait_for_decode_ready
call and the tracing target string "mocker::dis2147" to the chosen GitHub issue
identifier, and search within the same function/block (including tracing::warn!,
tracing::info! calls and any other occurrences around
server.wait_for_decode_ready, pin_start, abort_room, stream_tx, active_requests,
request_uuid) to replace all remaining "DIS-2147" references so they
consistently use the GitHub issue number.

In `@lib/mocker/src/common/protocols.rs`:
- Around line 711-718: Doc comment describing the "Timeout (milliseconds) for
the prefill→decode NIXL handshake" contains an internal ticket ID "DIS-2147";
remove that internal Linear reference and replace it with either a public GitHub
issue reference (e.g., GH-NNNN or `#NNNN`) or plain prose describing the behavior
change (e.g., "see related issue on repository" or "behavior introduced to
prevent resource leakage during late decodes") so the comment no longer includes
internal ticket identifiers; update the same doc block that mentions prefill
abort behavior to reflect the public reference or plain prose.

In `@lib/mocker/src/scheduler/sglang/config.rs`:
- Around line 43-45: Update the doc comment on the max_num_seqs field to replace
the internal ticket reference "DIS-2147" with the repository's GitHub issue
notation (e.g., "GH-NNNN" or "`#NNNN`") per guidelines; locate the comment above
the pub(super) max_num_seqs: u64 field and substitute the ticket string so the
comment reads something like "Used by the GH-<issue> decode-side admission wait
to model the seq-slot budget" (preserve surrounding text and formatting).

In `@lib/mocker/src/scheduler/sglang/core.rs`:
- Around line 252-266: The inline comment referencing "DIS-2147" should be
replaced with the approved GitHub issue format; locate the mocker_metrics block
where MockerMetrics is created (the block that sets metrics.max_num_seqs =
self.config.max_num_seqs) and update the comment "// DIS-2147: expose the
seq-slot cap for the decode-side admission wait." to use the GitHub issue
identifier (e.g., "// GH-<number>: expose the seq-slot cap for the decode-side
admission wait." or "// #<number>: ...") following the project's coding
guidelines so the reference is consistent and searchable.

In `@lib/mocker/src/scheduler/vllm/live.rs`:
- Around line 32-35: Doc comment for the struct field max_num_seqs contains an
internal Linear ticket reference "DIS-2147"; update that reference to the
corresponding GitHub issue format (e.g., replace "DIS-2147" with "`#2147`" or
"GH-2147") in the doc comment above pub max_num_seqs so it follows the project's
GitHub issue convention.

In `@lib/mocker/src/services/bootstrap.rs`:
- Around line 10-15: Replace all internal Linear ticket IDs "DIS-2147" in the
comments around the bootstrap behavior descriptions with a public reference or
plain prose; specifically update the comment lines that mention
wait_for_decode_ready(room_id, timeout), abort_room(room_id), and
complete_room(room_id) (and other occurrences noted in the comment) to either
reference a GitHub issue (e.g., GH-2147 or `#2147`) or describe it as "a
configured abort timeout" / "related capacity bug" instead of the internal
ticket ID so no Linear IDs remain in source comments and test labels.

---

Outside diff comments:
In `@lib/bindings/python/rust/llm/replay.rs`:
- Around line 174-209: The Python typing stub for the constructor is out of
sync: the Rust/pyO3 constructor function new now accepts the
kv_transfer_abort_timeout_ms keyword but the typed stub (_core.pyi) still ends
at kv_transfer_bandwidth; update the stub to add kv_transfer_abort_timeout_ms:
Optional[int] (or appropriate integer type) to the constructor signature so
typed callers no longer get unexpected-keyword errors, ensuring the parameter
name and type match the runtime signature of new and any related overloads in
the same stub.

In `@lib/mocker/src/scheduler/vllm/core.rs`:
- Around line 510-525: The inline comment in function mocker_metrics()
references a Linear ticket "DIS-2147"; update that comment to use the project's
GitHub issue format (e.g., "GH-<number>" or "#<number>") per guidelines by
replacing "DIS-2147" with the correct GitHub issue identifier in the comment
above metrics.max_num_seqs so the comment reads e.g. "// GH-XXXX: expose the
seq-slot cap..." instead of the Linear ticket; ensure only the comment text is
changed and code/logic (mocker_metrics, metrics.max_num_seqs) remains untouched.

In `@lib/mocker/src/services/bootstrap.rs`:
- Around line 190-216: The decode-side wait uses the global RENDEZVOUS_TIMEOUT,
causing premature timeouts; change the tokio::time::timeout calls (e.g. where
ImmediateOrWait::Wait(rx) matches) to use the room-specific abort timeout (the
prefill/kv_transfer_abort_timeout_ms value) instead of RENDEZVOUS_TIMEOUT,
obtaining that timeout from the Room or prefill params available in
bootstrap.rs/handle_connection() and applying the same change to the other
occurrences around lines 365-370; ensure you pass the correctly converted
Duration to tokio::time::timeout and only remove the room from
rooms.remove(&room_id) if the room-specific timeout elapses.

---

Nitpick comments:
In `@lib/llm/src/mocker.rs`:
- Around line 684-686: The tracing::warn! call is doing eager string
interpolation for room_id and e; replace it with lazy formatting by passing
room_id and e as structured fields or positional arguments to tracing::warn!
instead of embedding them in the format string—locate the tracing::warn!
invocation that references room_id and e in mocker.rs (the prefill aborting
transfer log) and change it to use structured fields like room_id = %room_id and
error = %e or use positional placeholders so the values are only formatted when
the warn level is enabled.

In `@lib/mocker/src/common/protocols.rs`:
- Around line 1251-1260: The round-trip test builds MockEngineArgs without
setting kv_transfer_abort_timeout_ms so it remains None and won't catch
serialization regressions; update the MockEngineArgs::builder() call used in the
round-trip test (the builder chain that sets worker_type, max_num_seqs,
max_num_batched_tokens, reasoning, sglang, etc.) to explicitly set
kv_transfer_abort_timeout_ms to a concrete non-default value (e.g., Some(1234)),
then after restore/assertion verify the restored
args.kv_transfer_abort_timeout_ms equals that value; apply the same change to
the other builder usage in the file that mirrors this test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 50e5f1b3-74a2-4ade-8460-66c44daeb240

📥 Commits

Reviewing files that changed from the base of the PR and between a95e0b1 and 36b4da0.

📒 Files selected for processing (10)
  • components/src/dynamo/mocker/args.py
  • components/src/dynamo/mocker/config.py
  • lib/bindings/python/rust/llm/replay.rs
  • lib/llm/src/mocker.rs
  • lib/mocker/src/common/protocols.rs
  • lib/mocker/src/scheduler/sglang/config.rs
  • lib/mocker/src/scheduler/sglang/core.rs
  • lib/mocker/src/scheduler/vllm/core.rs
  • lib/mocker/src/scheduler/vllm/live.rs
  • lib/mocker/src/services/bootstrap.rs

Comment thread components/src/dynamo/mocker/args.py Outdated
Comment thread lib/llm/src/mocker.rs Outdated
Comment thread lib/llm/src/mocker.rs Outdated
Comment thread lib/llm/src/mocker.rs Outdated
Comment thread lib/llm/src/mocker.rs Outdated
Comment thread lib/mocker/src/common/protocols.rs
Comment thread lib/mocker/src/scheduler/sglang/config.rs
Comment thread lib/mocker/src/scheduler/sglang/core.rs
Comment thread lib/mocker/src/scheduler/vllm/live.rs
Comment thread lib/mocker/src/services/bootstrap.rs Outdated
nnshah1 and others added 5 commits June 13, 2026 11:27
…t timeout

Adds optional NIXL-style backpressure to the online mocker's disagg
bootstrap rendezvous so cascade Steps 2 and 3 become reproducible
without GPU compute. Gated entirely on the new
MockEngineArgs::kv_transfer_abort_timeout_ms field. When None (default),
behavior is unchanged from pre-DIS-2147.

Mechanism:
  - Prefill side: after signal.completed and before complete_room,
    call server.wait_for_decode_arrival(room_id, timeout). The
    scheduler-side request stays active during the wait, so KV is
    pinned (modeling real disagg). On Ok -> normal handoff.
    On Err (timeout) -> server.abort_room() + surface error to client.
  - Decode side: before connect_to_prefill, poll
    scheduler.metrics_receiver until active_decode_blocks < total_blocks
    (free KV available). The act of connecting is decode's 'ready' signal.
  - Bootstrap server: new RoomOutcome enum, new prefill_waiting oneshot,
    new abort_room() with TTL cleanup, new ABORT_BYTE (0x02). Late decode
    on aborted room -> ABORT_BYTE -> clean error.

All 5 existing bootstrap tests pass; 5 new DIS-2147 scenario tests pass;
253 mocker lib tests pass; cargo check / fmt / clippy clean.

Linear: DIS-2147
Signed-off-by: nnshah1 <neelays@nvidia.com>
(cherry picked from commit aee86e0995113e1016f5cb081522c0fdd71d3773)
…EngineArgs

The DIS-2147 prefill-stranding extension added kv_transfer_abort_timeout_ms
to MockEngineArgs / MockEngineArgsBuilder in lib/mocker/src/common/protocols.rs
but never updated the pyo3 binding at lib/bindings/python/rust/llm/replay.rs.

Result: MockEngineArgs.__new__() rejects the kwarg with
"got an unexpected keyword argument 'kv_transfer_abort_timeout_ms'" when
the test framework tries to set it from YAML, which blocks the mocker
prefill-stranding sanity test that validates the cascade simulation
fidelity vs real GPU behavior.

Add the field to the #[pyo3(signature)] decorator, the fn arg list, and
the builder chain — mirrors kv_transfer_bandwidth which sits next to it
in the struct.

Signed-off-by: nnshah1 <neelays@nvidia.com>
(cherry picked from commit 8f36a982d1e135a52d2ddfaec676c7455e389aa1)
The original DIS-2147 implementation (aee86e09951) gave
wait_for_decode_kv_capacity its own timeout, sourced from the same
--kv-transfer-abort-timeout-ms value as the prefill-side abort. That
created a redundant decode-side error path that doesn't exist in real
engines:

- vLLM: WAITING_FOR_REMOTE_KVS requests stay WAITING until the
  scheduler admits them — no decode-side timer.
- sglang: PreallocQueue blocks on req_to_token_pool + token budget —
  no decode-side timer either.

In real disagg the only authoritative timer is the PREFILL side's
kv_transfer_abort_timeout_ms. When it fires, the prefill bootstrap
room closes. The decode side eventually attempts connect_to_prefill
on that room_id, finds it closed, and surfaces the failure via the
bootstrap connection — not via its own clock.

Empirical confirmation (this branch, 20-burst test before F1):
  18 prefill_kv_pin_end outcome="aborted"  ✓ authoritative
  36 "Decode KV wait timed out after 2s"   ← redundant; mocker-only

Remove the decode-side timeout. wait_for_decode_kv_capacity now blocks
on the watch channel indefinitely until both block-budget AND seq-slot
gates have headroom. Cancellation is delegated to the surrounding
request context (the future is dropped when the FE drops the request).

Signature change: wait_for_decode_kv_capacity drops the `timeout: Duration`
parameter. Only one caller in lib/llm/src/mocker.rs:777, updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>

(cherry picked from commit 8d1990b0e3b8c16a3db3d94de94b7b340858fecb)
Trim redundant inline comment in decode bootstrap path; the design
rationale already lives on wait_for_decode_kv_capacity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
@nnshah1
nnshah1 force-pushed the neelays/mocker-dis2147-stranding branch from 79d9d08 to b874918 Compare June 13, 2026 18:27

@PeaBrane PeaBrane 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.

Requesting changes because the current ordering does not create the KV-stranding behavior described by the PR: it waits before prefill is submitted, so no prefill KV is held and the overload cascade cannot be reproduced. The shared pinned-prefill scheduler state described inline should work cleanly for both the live mocker and replay harness.

Comment thread lib/llm/src/mocker.rs
// 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) => {

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.

Blocking: this wait happens before the later sender.send(direct_request), so the prefill request has not entered the scheduler or allocated any KV during the claimed pin interval. That models rendezvous delay, but not KV stranding or prefill-capacity exhaustion.

Please represent this as a shared scheduler lifecycle instead:

RunningPrefill -> PrefillPinned -> Released/Aborted

The scheduler should run prefill normally, retain the completed request and its KV allocation in PrefillPinned, include that KV in occupancy metrics, and expose an explicit release_pinned_prefill(request_id, outcome) transition. The live mocker can trigger that transition from decoder admission/transfer or timeout; replay can schedule the same transition as a simulation event. Keeping TCP/timeout orchestration outside the scheduler gives both paths identical resource accounting while allowing different timing drivers.

… state

Addresses @PeaBrane's review: the prior ordering submitted the disagg prefill to
the scheduler only AFTER wait_for_decode_ready resolved, so no KV was held during
the wait — the cascade could not form (the pin logs were aspirational).

- Ordering fix (mocker.rs): the disagg prefill is submitted FIRST so its KV is
  really allocated; wait_for_decode_ready now governs only the *release* of the
  already-pinned KV. Strand duration is event-driven and load-dependent (not a
  fixed time), matching the modeled cascade.
- Shared pinned scheduler state (vllm + sglang cores, kv managers): on prefill
  completion of a disagg stranding-candidate (request carries a bootstrap room),
  the request's blocks are moved into a pinned set instead of freed, and stay
  counted in active blocks so the pool fills. release_pinned() runs the deferred
  free.
- Release trigger by context: live → decode transfer-completion via bootstrap
  (release-pin control message), abort_timeout frees on no-show; replay (offline)
  → modeled time-based release (decode_end + handoff_delay), since offline can't
  observe cross-instance decode pickup. Driver stall-advances virtual time to the
  earliest pending pin deadline.

Tests: new test_disagg_prefill_strands_kv_until_release (vllm) + sglang analogue
prove the pool stays full while pinned, a second prefill is rejected (cascade),
and release drops occupancy to 0; non-bootstrap prefill does not strand (no
regression). cargo test -p dynamo-mocker (363) + dynamo-llm kv_router (19) pass;
fmt + clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
@nnshah1

nnshah1 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

@PeaBrane — addressed in 4a44959. You were right: the prior ordering submitted the prefill only after wait_for_decode_ready resolved (mocker.rs:719), so no KV was held during the wait and the cascade could not form.

Implemented the shared pinned-prefill scheduler state you described:

  • Ordering fix: the disagg prefill is now submitted to the scheduler first, so its KV is genuinely allocated; wait_for_decode_ready now governs only the release of the already-pinned KV.
  • Pin: on prefill completion of a stranding candidate (request carries a bootstrap room), its blocks move into a scheduler pinned set instead of being freed, and stay counted in active blocks so the pool fills (the cascade). release_pinned() runs the deferred free. Implemented in both the vLLM and sglang cores + KV managers.
  • Release trigger (the pin duration is event-driven / load-dependent, not a constant):
    • Live: the decode's transfer-completion via bootstrap (a release-pin control message); kv_transfer_abort_timeout_ms frees on a no-show.
    • Replay: offline can't observe which decode worker pulls or when it's admitted, so it uses a modeled time-based release (decode_end + handoff_delay); the driver stall-advances virtual time to the earliest pending pin deadline.

This is the "works cleanly for both the live mocker and replay harness" split you asked for.

Verification: new test_disagg_prefill_strands_kv_until_release (vLLM) + sglang analogue prove the pool stays full while pinned, a second prefill is rejected (cascade), and release drops occupancy to 0; a non-bootstrap prefill does not strand (no regression). cargo test -p dynamo-mocker (363) + dynamo-llm kv_router (19) pass; cargo fmt + clippy -D warnings clean.

One open design question for you: in replay the time-based release uses compute_prefill_handoff_delay_ms for the hold — that captures transfer time but not the decode-queueing delay (which offline can't see). LMK if you'd prefer a different replay approximation.

CI (test_disagg_background_prefill_sticky) caught that pinned prefill KV leaked
into the router-facing per-worker load metric (active_decode_blocks), inflating
the stranding worker's reported load and diverting even the no-sticky control
request away from normal KV-overlap routing.

The pinned-block count has two distinct consumers: (1) pool capacity / admission
— must include pinned KV so the pool fills and the cascade forms; (2) the
router's per-worker load signal (ActiveLoad.active_decode_blocks) used for
worker selection — must NOT include a stranded prefill awaiting handoff, or it
mis-diverts unrelated sequences.

Separate them: track a pinned_block_footprint (vllm) / pinned_token_footprint
(sglang) and subtract it from the router-facing metric only; the admission path
keeps reading num_active_blocks()/active_kv_blocks() directly (cascade
preserved). sglang stranding test repointed its pool/cascade assertions to a
capacity-view helper and now also asserts the router metric IS discounted.

Verified: test_disagg_background_prefill_sticky passes; stranding unit tests
(vllm+sglang) + non-bootstrap-no-strand pass; cargo test -p dynamo-mocker (363)
+ dynamo-llm kv_router (167) pass; fmt + clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
Captures intent + mechanism for modeling disagg prefill-KV stranding via the
engine-opaque disaggregated_params (vLLM NIXL kv_transfer_params) instead of the
sglang-shaped bootstrap rendezvous: prefill emits a transfer handle + pins KV;
decode carries it and pulls → event-driven release (live via the reframed
channel, replay via in-process correlation). sglang behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
@nnshah1
nnshah1 requested a review from a team as a code owner June 15, 2026 14:08
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jun 15, 2026
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Re-architect disagg prefill->decode KV stranding to the vLLM/NIXL model
(engine-opaque disaggregated_params) instead of the sglang-shaped bootstrap
rendezvous. Mocker-side only — no prefill_router changes. Spec:
docs/superpowers/specs/2026-06-15-mocker-vllm-disagg-params-stranding-design.md

vLLM path:
- Prefill emits real disaggregated_params {transfer_id, prefill_host,
  prefill_port} (replacing the dummy) and its request stream COMPLETES normally
  — no stream-holding — so it flows through the existing prefill_router drain
  without deadlock. Matches real vLLM, where the NixlConnector holds KV
  independent of the request lifecycle.
- The scheduler pin (pin_completed) is decoupled from the request and survives
  its completion; pinned KV counts toward pool capacity (cascade) but is
  discounted from the router-facing load metric (so stranding doesn't mis-divert
  overlap routing).
- Release is channel-server-driven and event-driven, keyed by transfer_id:
  the prefill registers a pin with the BootstrapServer; a decode connect fires
  the release (then ACK); kv_transfer_abort_timeout_ms with no decode releases +
  ABORT. Strand duration is load-dependent (until that request's decode pulls),
  never a fixed time.
- Replay: in-process transfer_id -> uuid correlator releases the pin when the
  matching decode is scheduled (time-based release removed).

Channel: key generalized room_id -> transfer_id with RoomId/TransferId (=u64)
aliases so each engine keeps native vocabulary; connect/wait/ACK/ABORT behavior
unchanged. sglang keeps its bootstrap-triple disaggregated_params format and
behavior (path-gated on !vllm_disagg_handoff) — unchanged.

Verified in-container: dynamo-mocker 366 + dynamo-llm mocker 6 + kv_router 167
pass; new bootstrap pin tests (decode-connect release, abort-timeout release) +
replay event-driven release test; fmt + clippy -D warnings clean. prefill_router
untouched; sglang tests unchanged. The multi-process live e2e
(test_disagg_background_prefill_sticky) is covered by CI (not runnable locally —
needs frontend+NATS+etcd+bindings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
Address review (graham):
- vLLM disagg transfer_id was rand::random() into a long-lived channel/pin
  registry — a collision would silently mis-release/leak a pinned KV. Use a
  process-monotonic AtomicU64 (collision-free), matching the replay path.
- Build the emitted disaggregated_params via serde_json::json! (infallible)
  instead of to_value(..).expect() on the live prefill path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: nnshah1 <neelays@nvidia.com>
…-worker busy-spin

Make the vLLM disaggregated_params (NIXL-style) KV-stranding path engage in a live, multi-process mocker deployment with no bootstrap rendezvous and no router changes:

- config.py: a vLLM disagg prefill registers set_disaggregated_endpoint(None, None) so the unchanged prefill_router takes its existing NoBootstrapEndpoint -> output-disaggregated_params branch. sglang keeps advertising host/port (bootstrap path) and is byte-for-byte unchanged (vLLM-gated). main.py threads engine_type into build_runtime_config (the pyclass exposes no getter).

- mocker.rs: start the KV-transfer server for a vLLM prefill on an OS-assigned port even without --bootstrap-ports; emit {transfer_id, host, port} in disaggregated_params; decode reads it and connects. Add is_vllm() helper.

- live.rs: receive_requests now blocks when there is no *runnable* work (not when fully empty). A pinned-only worker previously hot-spun (is_empty() counts pinned), starving the decode workers whose admission releases the pin -> runaway prefill->decode handoff latency. A pinned-only worker can only advance via request_rx (new request or release_pin), so blocking on recv() is correct and deadlock-free.

- bootstrap.rs: register_pin always installs the reclaim timer (default RENDEZVOUS_TIMEOUT when no explicit abort timeout) for parity with sglang's wait_for_decode_ready, and stores the timer's AbortHandle so a decode-connect release cancels it (no lingering task on the happy path).

Signed-off-by: nnshah1 <neelays@nvidia.com>
nnshah1 added 2 commits June 15, 2026 13:06
…ss, abort parity

Signed-off-by: nnshah1 <neelays@nvidia.com>
End-to-end check that Least Loaded frontend routing drives vLLM mocker prefill/decode workers launched WITHOUT --bootstrap-ports over the disaggregated_params (NIXL) path and strands/releases KV. The pin lifecycle (prefill_kv_pin_start / decode_kv_wait_start / prefill_kv_pin_end) is observable in worker logs under DYN_LOG=mocker::kv_abort=info.

Signed-off-by: nnshah1 <neelays@nvidia.com>
…he PR

Signed-off-by: nnshah1 <neelays@nvidia.com>

@PeaBrane PeaBrane 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.

Please broaden the new end-to-end test across the relevant router modes.

enforce_disagg=True,
request_plane=request_plane,
event_plane="nats",
router_mode="least-loaded",

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.

There’s no reason to limit this coverage to Least Loaded, even though it motivated the test. Please parameterize router_mode and cover at least round-robin, kv, and least-loaded so the bootstrap-free disaggregated_params path is validated independently of routing mode.

@PeaBrane PeaBrane 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.

The scheduler-level stranding model is useful, but the current implementation needs clearer core boundaries, extraction of the live disaggregation orchestration, and a faithful shared lifecycle for SGLang and vLLM before merging.

/// the matching decode pulls the transfer. `None` disables stranding (the
/// request frees normally on completion).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap_room: Option<u64>,

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.

Pinning belongs in the core scheduler, but bootstrap_room and release_pin expose live transport concepts here and make DirectRequest double as a control command. Could we model this explicitly, for example:

enum CompletionPolicy {
    Release,
    PinUntilReleased,
}

enum SchedulerInput {
    Request {
        request: DirectRequest,
        completion_policy: CompletionPolicy,
    },
    ReleasePinned {
        uuid: Uuid,
    },
}

Live and replay can retain their own SGLang room / vLLM transfer-ID to scheduler-UUID correlation outside core. This keeps the reusable core responsible for scheduler lifecycle rather than transport vocabulary.

Comment thread lib/llm/src/mocker.rs
// `transfer_id`. The server fires the registered `release_pin` when the
// matching decode connects (then ACKs it) or on abort-timeout.
#[allow(clippy::type_complexity)]
let (prefill_transfer_id, emitted_disagg_params, vllm_disagg_handoff): (

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.

This has grown into a substantial disaggregated-transfer subsystem inside generate(): engine-specific wire parsing, transfer-ID generation, decode-capacity waiting, TCP coordination, pin callback registration, timeout handling, and stream ordering. Please extract it into mocker/disagg.rs behind typed structures, for example a DisaggProtocol plus a DisaggHandoff/state object. generate() should mainly resolve the handoff, submit the scheduler request, run the required lifecycle gates, and forward scheduler output.

Comment thread lib/llm/src/mocker.rs
// The TWO disagg paths differ in WHO drives the pin release, and the
// difference is intentional — vLLM is the NIXL-faithful reframe:
//
// - **sglang bootstrap path** (`bootstrap_info` present): the decode

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.

Cross-checking current SGLang, this is not actually unchanged. With the default optimistic_prefill_retries=0, decode physically preallocates KV and publishes destination metadata before prefill leaves its bootstrap queue. Only optimistic-prefill mode computes early enough to strand on decode capacity. SGLang also retains source KV until transfer success, not merely until decode connects.

I still think both engines should use a shared typed lifecycle, but it needs distinct states/events such as SourcePinned, DestinationReserved, Transferring, Completed, and Aborted. Please either preserve baseline SGLang ordering or expose optimistic-prefill behavior explicitly, and release the source pin only after the modeled transfer completes.

@PeaBrane PeaBrane 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.

There are a lot of independently risky moving parts bundled here: scheduler pin ownership for two engines, live channel rendezvous/reclaim, bootstrap-free endpoint registration, decoder admission, replay correlation, configuration/API changes, and the router E2E. It may be worth staging this as a small dependent PR series so each invariant is easier to review and validate, for example: (1) scheduler pin/release primitive plus core tests, (2) live vLLM handoff, endpoint advertising, and reclaim, (3) scheduler-owned decode admission/reservation, then (4) replay/SGLang parity and end-to-end coverage. The existing changes-requested verdict remains appropriate while the correctness points below and the earlier architecture comments are addressed.

Comment thread lib/llm/src/mocker.rs
// 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 {

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.

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. connect_to_prefill then releases the source pin before the DirectRequest reaches the scheduler. Replay has the same issue when it releases at dispatch_decode. The pin release should be driven by a scheduler-owned reservation/admission event for this specific request.

Comment thread lib/llm/src/mocker.rs
"prefill_kv_pin_start"
);
let release_sender = sender.clone();
server.register_pin(

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.

The abort clock starts here immediately after scheduler submission, not when the scheduler actually transitions the completed prefill into pinned state. If prefill compute exceeds the timeout, release_pin removes the pin candidate before completion, so the request never strands and the configured transfer timeout incorrectly includes compute time. Please register/start this lifecycle from the scheduler's pin transition, or add an explicit pin-ready notification.

pub(crate) fn is_empty(&self) -> bool {
self.waiting.is_empty() && self.running.is_empty()
// a worker holding pinned (stranded) KV is not idle.
self.waiting.is_empty() && self.running.is_empty() && self.pinned.is_empty()

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.

This change makes a pinned-only SGLang core non-empty, but the unchanged live SGLang loop still blocks only on core.is_empty(). While a pin is held it will therefore repeatedly execute zero-work, zero-duration passes instead of waiting on request_rx. vLLM was changed to gate on !has_runnable_work() for exactly this reason; SGLang needs the same live-loop change and coverage.

Comment thread lib/llm/src/mocker.rs
// The mocker prefill/decode run as local processes; the
// bootstrap server binds 0.0.0.0, so loopback is the
// reachable pull address for the modeled transfer.
"prefill_host": "127.0.0.1",

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.

This is not reachable in a normal distributed mocker deployment: the repository's disaggregated mocker examples run prefill and decode as separate pods, so 127.0.0.1 on decode refers to the decode pod, not prefill. Please emit the configured advertised address, such as DYN_HTTP_RPC_HOST/the runtime registration host, together with the bound port.

Comment thread lib/llm/src/mocker.rs
request
.prefill_result
.as_ref()
.and_then(|r| VllmDisaggParams::parse(&r.disaggregated_params))

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.

A present but malformed prefill_result.disaggregated_params is silently treated as no handoff, so this decode proceeds as aggregated while the source remains pinned until timeout. Absence can represent a genuinely aggregated request, but present-invalid handoff data should return a structured error rather than weakening the contract.

discovery_backend = "etcd"
request_plane = "tcp"
shared_namespace = f"test-namespace-{generate_random_suffix()}"
mocker_args = {"speedup_ratio": SPEEDUP_RATIO, "block_size": BLOCK_SIZE}

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.

This test does not currently distinguish the stranding implementation from an immediate-free implementation: requests are sequential, the KV pool is not constrained, kv_transfer_abort_timeout_ms remains None so the capacity wait is disabled, and the only assertion is request success. Please create actual decoder pressure/concurrency and assert an observable pin/admission/reclaim outcome. The test command helper will also need to forward the timeout argument if this path is enabled through it.

…replay

Offline disagg replay released the stranded prefill's pin at decode *routing* (dispatch_decode = the router's worker pick), so the strand only modeled rendezvous latency, not slot-limited stranding — a decode-saturated run produced byte-for-byte the same metrics as no stranding.

Move the release to the decode engine's *slot admission*: dispatch_decode is now pure routing (enqueue into the decode engine); release_prefill_pin_for fires from handle_decode_engine_effects for each request the decode engine admits (secures a seq-slot + KV blocks and pulls the transfer). A completion-path safety net releases pins for decodes that complete/reject without ever being admitted. Under decode saturation the admission — and thus the release — is delayed, so the prefill's KV stays pinned and pressures the prefill pool (the cascade), matching the live model.

Signed-off-by: nnshah1 <neelays@nvidia.com>
@nnshah1
nnshah1 marked this pull request as draft June 22, 2026 21:00
@nnshah1

nnshah1 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

will be done separately -

@nnshah1 nnshah1 closed this Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation feat size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants