[TRTLLM-12499][feat] Pipelined KVCache transfer for disaggregated serving in Python Cache Transceiver - #15727
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds sender-side pipelined KV transfer for disaggregated serving. The change introduces block projection, separate sender and receiver identifiers, session retirement, configuration validation, executor integration, and unit and integration coverage. ChangesPipelined KV transfer
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant PyExecutor
participant KvCacheTransceiverV2
participant TxSession
participant RxSession
PyExecutor->>KvCacheTransceiverV2: send prefill chunk
KvCacheTransceiverV2->>TxSession: send projected KVSlice
TxSession->>RxSession: deliver KV result with sender and receiver IDs
RxSession-->>PyExecutor: resolve receiver task and report completion
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
tensorrt_llm/_torch/disaggregation/native/transfer.py (1)
488-507: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winMark the task as in-flight before waiting on the CUDA event.
Line 492 waits while the task is still
INIT, socancel_request()can see noTRANSFERRINGtasks and free KV pages before the event completes. Move the INIT→TRANSFERRING transition before the event wait, and keep the cancelled/error abort path before synchronization.Suggested fix
- # For pipelined prefill-transfer: wait for the GPU forward - # to finish writing KV data before starting RDMA. This - # blocks only this worker thread, not the GPU or main thread. - if task._slice.cuda_event is not None: # TODO: should I sync after the task status is set to TRANSFERRING? - task._slice.cuda_event.synchronize() - - if timer: - timer.record_push_end(write_meta.peer_rank) # Hold session.lock to serialize the INIT→TRANSFERRING transition with # cancel(): prevents cancel_request() from freeing KV pages while a # worker is about to write into them. with session.lock: status = session.status if status in (SessionStatus.ERROR, SessionStatus.CANCELLED): should_abort = True else: task.status = TaskStatus.TRANSFERRING should_abort = False + + if should_abort: + ... + return + + # For pipelined prefill-transfer: wait for the GPU forward + # to finish writing KV data before starting RDMA. + if task._slice.cuda_event is not None: + task._slice.cuda_event.synchronize() + + if timer: + timer.record_push_end(write_meta.peer_rank)🤖 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 `@tensorrt_llm/_torch/disaggregation/native/transfer.py` around lines 488 - 507, The task transition in transfer.py is happening too late in the prefill-transfer flow: `task._slice.cuda_event.synchronize()` runs while the task is still `INIT`, so `cancel_request()` can miss it and free KV pages too early. In the transfer path around `task`, `session.lock`, and `TaskStatus.TRANSFERRING`, move the INIT→TRANSFERRING state update (with the session ERROR/CANCELLED abort check) before waiting on the CUDA event, and keep the abort branch ahead of synchronization so in-flight work is visible before any blocking wait.
🧹 Nitpick comments (2)
tensorrt_llm/_torch/disaggregation/transceiver.py (2)
582-585: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRedundant session assignment.
_get_or_create_send_sessionalready inserts the session intoself._send_sessions, so re-assigning the return value is redundant (and could mask a future divergence between the two code paths). Mirror the simpler form used inrespond_and_send_async.🤖 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 `@tensorrt_llm/_torch/disaggregation/transceiver.py` around lines 582 - 585, The send-session initialization in transceiver logic has a redundant assignment because _get_or_create_send_session already stores the session in self._send_sessions. Update the rid-not-in-self._send_sessions branch in transceiver.py to follow the same pattern as respond_and_send_async by simply invoking _get_or_create_send_session(req) for its side effects, then keep setting _ever_had_send_session and _pipelined_chunk_offsets[rid] as before.
602-604: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNumerous open TODOs in the pipelined-send path before merge.
send_prefill_chunkandrespond_and_send_asynccarry several unresolvedTODO(athenac)questions on correctness-critical fields (token_range,mamba_state_index,layer_range, thereq.statetransition, the offset accumulation "might be a faulty calculation", and the redundancy between the two methods). Since the PR is marked WIP, these need resolution before this is production-ready. I can help draft the offset/metadata handling and consolidate the shared logic into a single helper.Also applies to: 608-611, 628-631, 657-666, 675-675
🤖 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 `@tensorrt_llm/_torch/disaggregation/transceiver.py` around lines 602 - 604, The pipelined-send path in transceiver.py still contains unresolved correctness TODOs in send_prefill_chunk and respond_and_send_async, especially around token_range, mamba_state_index, layer_range, req.state transitions, and the offset accumulation logic. Resolve these TODO(athenac) questions by verifying the metadata semantics, fixing the offset calculation, and making the state update explicit and correct before merge. Also remove the duplicated logic between send_prefill_chunk and respond_and_send_async by consolidating the shared send/metadata assembly into a single helper so the two paths stay consistent.
🤖 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 `@tensorrt_llm/_torch/disaggregation/native/transfer.py`:
- Around line 728-745: The chunked destination slicing in transfer logic is now
using chunk offsets, but the token alignment still assumes each chunk maps to a
suffix ending at token_range.end. Update the code around the chunked path in
transfer.py and the downstream token-start calculation to derive starts from
chunk_block_offset, or require callers to provide per-chunk KVSlice.token_range
for each chunk. Make sure the block selection and token-range alignment stay
consistent for prefix-cache and SWA cases so the written blocks match the
intended chunk.
- Around line 523-524: The abort/result notification path in transfer.py still
uses write_meta.slice_id, which can conflict with the receiver’s single-task
slice handling. Update the abort send logic in the relevant transfer routine to
mirror the success path by reporting receiver_slice_id as 0 for aborts too, so
the receiver does not see a later-chunk slice ID and hit its slice assertion.
Keep the existing task/event unblocking behavior intact while ensuring the
aborted/failure result is always sent to receiver slice 0.
- Around line 736-743: The chunk-to-destination mapping in transfer.py is too
strict for exhausted layer groups: when len(src_block_ids) is 0, the current
bounds check in the chunk slicing logic still raises on advanced chunk_offset
values. Update the chunk handling around the dst_block_ids slice so empty source
chunks become a no-op and do not trigger the out-of-bounds error; keep the
existing bounds validation for non-empty chunks in the same chunk
offset/full_dst_block_ids path.
In `@tensorrt_llm/_torch/disaggregation/transceiver.py`:
- Line 648: The `respond_and_send_async` skip guard in `transceiver.py` needs
both a lint fix and a logic check: move the `return` onto its own line to
satisfy E701, and verify the condition around `rid in self._send_sessions and
rid not in self._pipelined_chunk_offsets` correctly prevents duplicate sends
while pipelined chunks are still outstanding. If needed, adjust the guard so the
full `_create_kv_slices` resend path only runs when it is safe, using the
existing `_send_sessions` and `_pipelined_chunk_offsets` state to avoid
duplicate transfer.
- Around line 591-611: The chunking logic in send_prefill_chunk() and the
_pipelined_chunk_offsets update can split KV slices on token boundaries that are
not aligned to tokens_per_block, which causes the boundary block to be resent
and offsets to drift. Adjust the prefill chunk selection so every chunk boundary
lands on a KV block boundary (or clamp the sliding-window fallback so it only
overlaps when it evenly divides tokens_per_block), and then recompute
_pipelined_chunk_offsets from the actual block count in the chunk.
In `@tests/unittest/disaggregated/test_kv_transfer.py`:
- Around line 1789-1825: The send/receive flow is using the wrong API shape:
TxSession.send() and RxSession.receive() should be called with a fully populated
KVSlice rather than extra kwargs, and they do not return futures. Update the
test setup around KVSlice, sender_session.send(), and
receiver_sessions/RxSession.receive() to set chunk_block_offset and cuda_event
on the slice object before calling send/receive, then replace the .result()
waits with wait_complete()/wait_complete(blocking=True) on the session or slice
as appropriate.
---
Outside diff comments:
In `@tensorrt_llm/_torch/disaggregation/native/transfer.py`:
- Around line 488-507: The task transition in transfer.py is happening too late
in the prefill-transfer flow: `task._slice.cuda_event.synchronize()` runs while
the task is still `INIT`, so `cancel_request()` can miss it and free KV pages
too early. In the transfer path around `task`, `session.lock`, and
`TaskStatus.TRANSFERRING`, move the INIT→TRANSFERRING state update (with the
session ERROR/CANCELLED abort check) before waiting on the CUDA event, and keep
the abort branch ahead of synchronization so in-flight work is visible before
any blocking wait.
---
Nitpick comments:
In `@tensorrt_llm/_torch/disaggregation/transceiver.py`:
- Around line 582-585: The send-session initialization in transceiver logic has
a redundant assignment because _get_or_create_send_session already stores the
session in self._send_sessions. Update the rid-not-in-self._send_sessions branch
in transceiver.py to follow the same pattern as respond_and_send_async by simply
invoking _get_or_create_send_session(req) for its side effects, then keep
setting _ever_had_send_session and _pipelined_chunk_offsets[rid] as before.
- Around line 602-604: The pipelined-send path in transceiver.py still contains
unresolved correctness TODOs in send_prefill_chunk and respond_and_send_async,
especially around token_range, mamba_state_index, layer_range, req.state
transitions, and the offset accumulation logic. Resolve these TODO(athenac)
questions by verifying the metadata semantics, fixing the offset calculation,
and making the state update explicit and correct before merge. Also remove the
duplicated logic between send_prefill_chunk and respond_and_send_async by
consolidating the shared send/metadata assembly into a single helper so the two
paths stay consistent.
🪄 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: 784a62c4-609a-4545-bc83-7d70311b9078
📒 Files selected for processing (11)
tensorrt_llm/_torch/disaggregation/base/transfer.pytensorrt_llm/_torch/disaggregation/native/transfer.pytensorrt_llm/_torch/disaggregation/transceiver.pytensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.pytensorrt_llm/_torch/pyexecutor/py_executor.pytensorrt_llm/llmapi/llm_args.pytests/integration/defs/accuracy/test_disaggregated_serving.pytests/integration/test_lists/test-db/l0_dgx_b200.ymltests/unittest/disaggregated/test_chunked_transfer.pytests/unittest/disaggregated/test_kv_transfer.pytests/unittest/llmapi/test_llm_args.py
|
@coderabbitai check the test coverage, including function test, perf test, stress test and multi nodes test. |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Changed/mentioned test files =="
for f in \
tests/unittest/disaggregated/test_chunked_transfer.py \
tests/unittest/disaggregated/test_kv_transfer.py \
tests/unittest/llmapi/test_llm_args.py \
tests/integration/defs/accuracy/test_disaggregated_serving.py \
tests/integration/test_lists/test-db/l0_dgx_b200.yml \
tensorrt_llm/_torch/disaggregation/transceiver.py \
tensorrt_llm/_torch/pyexecutor/py_executor.py \
tensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.py
do
if [ -f "$f" ]; then
echo "-- outline: $f"
ast-grep outline "$f" --view expanded || true
echo
else
echo "Missing: $f"
fi
done
echo "== Search for perf/stress/multi-node/disaggregated related tests =="
rg -n -i -C2 \
'(perf|performance|benchmark|stress|soak|multi[-_ ]node|multinode|disaggregated serving|disaggregated|chunked|pipelined)' \
tests tensorrt_llm/_torch | sed -n '1,260p'
echo
echo "== Inspect relevant slices from changed tests =="
sed -n '1,260p' tests/unittest/disaggregated/test_chunked_transfer.py
echo "-----"
sed -n '1,260p' tests/unittest/disaggregated/test_kv_transfer.py
echo "-----"
sed -n '1,220p' tests/integration/defs/accuracy/test_disaggregated_serving.py
echo "-----"
sed -n '1,220p' tests/unittest/llmapi/test_llm_args.py
echo "-----"
sed -n '1,220p' tests/integration/test_lists/test-db/l0_dgx_b200.yml
echo
echo "== Inspect relevant implementation slices for testability points =="
sed -n '1,260p' tensorrt_llm/_torch/disaggregation/transceiver.py
echo "-----"
sed -n '1,220p' tensorrt_llm/_torch/pyexecutor/py_executor.py
echo "-----"
sed -n '1,220p' tensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.pyLength of output: 144072 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Direct test references for new pipelined/chunked entry points =="
rg -n -C2 \
'_maybe_send_prefill_chunk|send_prefill_chunk|enable_pipelined_transfer|respond_and_send_async|request_and_receive_async|_create_kv_slices|create_kv_cache_transceiver' \
tests tensorrt_llm/_torch | sed -n '1,260p'
echo
echo "== Disaggregated multi-node / stress / soak style tests in test tree =="
rg -n -i -C2 \
'(multi[-_ ]node|multinode|soak|stress|long[-_ ]run|longevity|burn[-_ ]in)' \
tests | sed -n '1,220p'
echo
echo "== Show pipelined accuracy test and nearby YAML entries =="
sed -n '720,820p' tests/integration/defs/accuracy/test_disaggregated_serving.py
echo "-----"
rg -n -C2 'chunked_kv_transfer|pipelined_kv_transfer' tests/integration/test_lists/test-db/l0_dgx_b200.ymlLength of output: 49120
What is covered wellFunction / unit tests
Integration / accuracy
Gaps I found1. Some new entry points are not directly tested
So the core mechanics are exercised indirectly, but several newly added integration points are still missing targeted tests. 2. Pipelined accuracy test does not appear to be scheduled in the shown pre-merge list
So pipelined accuracy exists in code, but it does not look wired into this pre-merge list yet. 3. No PR-specific perf validation
Given the PR objective is explicitly performance-oriented, this is a notable gap. 4. No PR-specific stress coverage
But I did not find stress coverage specifically targeting:
5. No PR-specific multi-node coverage
But I did not find multi-node tests specifically exercising this PR’s new chunked/pipelined KV transfer path. Overall assessment
Recommended follow-ups
If you want, I can turn this into a concrete review comment with prioritized test additions. |
7705b98 to
37f1bdd
Compare
37f1bdd to
7173ea6
Compare
d3de936 to
aa98b71
Compare
…ving in Python Cache Transceiver Instead of waiting for all prefill chunks to complete before starting KV cache transfer, each chunk's KV data is transferred to the generation server immediately after its prefill completes. This overlaps GPU compute with RDMA transfer, hiding transfer latency behind prefill computation. Only the last chunk's transfer remains on the critical path. The feature is gated behind `enable_pipelined_transfer` on `CacheTransceiverConfig` and is implemented in `KvCacheTransceiverV2` only. It requires `schedule_style: generation_first`, `enable_chunked_prefill: true`, `beam_width == 1`, the NIXL backend, `kv_cache_bounce_size_mb == 0`, `pipeline_parallel_size == 1` on the sender, and a non-Mamba/hybrid cache manager. Each requirement is enforced at startup or per request. Squashed from 15 commits: - Chunking is sender-side only; the generation server posts a single receive covering the whole prompt and completes on `is_last_slice`. - `KVSlice` now describes one chunk rather than one whole request, gaining `total_blocks` and a meaningful `is_last_slice`. `prompt_len` became required on the session args so SWA can compute the stale-block boundary. - `project_blocks_to_global_chunk` intersects ranges instead of indexing, so resident-suffix block lists (sliding window groups, prefix reuse, incremental allocation) project correctly onto a global chunk. - The first slice always extends back to block 0, so a context-side prefix-reuse hit does not leave `[0, prepopulated_prompt_len)` unsent. - Source blocks are capped at the computed chunk boundary before SWA trimming, normalizing V1's full-prompt reservation against V2's incremental allocation. - `KV_AGENT_RESULT` carries `sender_slice_id` and `receiver_slice_id` separately, making per-chunk RDMA failures attributable. Behavior-neutral for the monolithic receiver. - KV transfer activity is modeled by transceiver session membership rather than `LlmRequestState`, so mid-prefill cancellation and transfer-timeout monitoring work during the pipelined phase. - A retired send session cannot be silently re-created, since closing it drops the peer's `RecvReqInfo` and the receiver never re-registers. - `TxSession.dispatch_lock` serializes chunk dispatch across the executor thread and the late-peer replay path, so a newer slice cannot reach a peer's queue ahead of an older one. - Transceiver configuration resolution happens early and idempotently, and backend/runtime compatibility validation is centralized. Signed-off-by: Athena Cai <athenac@nvidia.com> Simpilify _build_kv_write_meta logic Signed-off-by: Athena Cai <athenac@nvidia.com> Carry the pipelined chunk window as a TokenRange The chunk cursor on KVSlice goes back to the existing TokenRange rather than a new ChunkCoords dataclass, so the slice keeps one field for "how far does this slice reach" instead of gaining a second vocabulary for it. The window is still decided in block space by _build_prefill_chunk, so the range it emits is block-aligned and the sender asserts that before dividing it back out. TokenRange now admits an empty range, which a chunk clamped past the end of the prompt produces. Signed-off-by: Athena Cai <athenac@nvidia.com> Drop cancellation, timeout, and failure-path chunked transfer tests Removes the error-path coverage from test_chunked_transfer.py: the cancelled-request send gates, the transfer-timeout sweeps, the session ERROR/FAILED status cases, and the retired-send-session block. The file now covers chunk projection, slice-id addressing, dispatch ordering, and the pipelined config gates only. Signed-off-by: Athena Cai <athenac@nvidia.com> Restore WriteMeta's single slice_id field WriteMeta carried a sender_slice_id/receiver_slice_id pair so the sender could log its own chunk index while addressing the peer's task on the wire. Only the peer's index is needed, so the field goes back to the pre-existing slice_id and _deliver_kv_to_agent resolves the send task from write_meta.task instead of indexing the session by chunk. Sender logs lose per-chunk attribution. Signed-off-by: Athena Cai <athenac@nvidia.com> Remove KVSlice.total_blocks Signed-off-by: Athena Cai <athenac@nvidia.com> Round down at block boundaries Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com> revert _send_kv_result_to_receiver Signed-off-by: Athena Cai <athenac@nvidia.com> Refactor project_blocks_to_global_chunk Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
98a2022 to
0c5029b
Compare
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Shixiaowei02
left a comment
There was a problem hiding this comment.
Thanks for turning all of this around so quickly. Most of the last round is properly addressed. Here are some comments based on the agent review workflow.
| # Order matters: start_transfer commits the request's blocks to the reuse | ||
| # tree and pins them, and must run before respond_and_send_async sends the | ||
| # final KV slice and (for the Python transceiver) transitions the request toward completion. | ||
| self.async_transfer_manager.start_transfer(req) |
There was a problem hiding this comment.
This function is unchanged from main, and on main there is no reason to split it: it is called once, when prefill ends, so taking ownership of the request for the fabric and doing the end of context work happen at the same moment.
Pipelining separates those two moments, and the way the PR covers the gap is by adding a second ownership record on the transceiver and a flag on the request. Those two are new here, so I think they need resolving in this PR rather than later, one way or the other.
The clean version is to split this into an acquire that runs on the first chunk and a completion that runs on the last. Then the places that ask whether a request is still owned by the fabric all become correct on their own, the extra record and the flag can go, and the timeout clock gets a natural place to start. I realise that touches the connector path too, so if you would rather keep the extra records, could you say in a comment why they are the right call and what keeps them in step with the manager? Right now there are three answers to the same question and they do not all agree.
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
…ating the extra _ctx_consensus() collective per sweep. Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Signed-off-by: Athena Cai <athenac@nvidia.com>
Documentation (WIP) https://docs.google.com/document/d/1Z9ARCc48QNCbKfoTEhZT1W4W440x6QsbKfVh3ksKIW0/edit?tab=t.0
Status: In review
Follow up PR to remove redundant KVCM work: #17526
Description
Summary
Implements pipelined prefill-transfer for disaggregated serving. Instead of waiting for all prefill chunks to complete before starting KV cache transfer, each chunk's KV data is transferred to the generation server immediately after its prefill completes. This overlaps GPU compute with RDMA transfer, hiding transfer latency behind prefill computation. When transfer time per chunk is less than prefill time per chunk (typical for 100+ Gbps NIC with long-context workloads), transfer latency is nearly fully hidden.
Only the last chunk's transfer remains on the critical path. Everything before it is paid for out of prefill compute time.
Related work by @chienchunhung for chunked KV cache transfer: Reducing KV Block Residency and Peak Memory Pressure in Disaggregated Serving
Configuration
The feature is gated behind
enable_pipelined_transferonCacheTransceiverConfig(tensorrt_llm/llmapi/llm_args.py). The flag is consumed by the Python transceiver only and has no C++ counterpart (_to_pybinddoes not forward it).Requirements and where they are enforced
schedule_style: generation_first_validate_disagg_configintensorrt_llm/llmapi/disagg_utils.py(startup, from the disagg YAML) andPyExecutor._validate_request(per request)enable_chunked_prefill: truereq.py_last_context_chunk); there is no separate chunk-size knobcreate_py_executor(tensorrt_llm/_torch/pyexecutor/py_executor_creator.py,ValueError)beam_width == 1PyExecutor._validate_request(ValueError), asserted again in_build_prefill_chunkKvCacheTransceiverV2resolve_cache_transceiver_config(ValueError)kv_cache_bounce_size_mb == 0resolve_cache_transceiver_config(ValueError)pipeline_parallel_size == 1on the sendercreate_kv_cache_transceiver, gated onis_kv_cache_sender = getenv("TRTLLM_DISAGG_ROLE") != "generation"(ValueError); the generation server is unaffected and may still use PPcreate_kv_cache_transceiverrejectsisinstance(kv_cache_manager, MambaHybridCacheManager) or mamba_cache_manager is not None(ValueError)resolve_cache_transceiver_config(kv_cache_transceiver.py) is the single place that resolves defaults and validates runtime-independent configuration: it collapsestransceiver_runtime == "auto"toNone, performs the pipelined-transfer auto-selection below, rejects aPYTHONruntime paired with a non-NIXL backend, and rejectskv_cache_bounce_size_mb > 0together withenable_pipelined_transfer. It is called twice. First, at the top ofcreate_py_executor(py_executor_creator.py), beforeKvCacheCreatorallocates the KV cache pool or selects a manager class — two things downstream readcache_transceiver_config.transceiver_runtimeand would disagree with the transceiver actually constructed later if it were still unresolved at that point:get_kv_cache_manager_clsuses it to decide betweenMixedMambaHybridCacheManagerand the C++-compatible hybrid managers for Mamba/hybrid models, and_maybe_enable_fabric_memory_for_python_transceiveruses it to decide whether to defaultTRTLLM_KVCACHE_POOL_USE_FABRIC_MEMORY=1for the C++ KV cache pool (needed for MNNVL transfers out of that pool by the Python transceiver) — and that env var is read once and cached by C++ on first access, so it must be set before any pool allocation. Second, at the top ofcreate_kv_cache_transceiveritself, which is the only callresolve_cache_transceiver_configgets on paths that don't go throughcreate_py_executor(e.g. AutoDeploy'sad_executor.py). The second call is a no-op in the common case: oncetransceiver_runtimehas been resolved away from"auto"/None, the auto-selection branch and theCPP-conflict branch it could otherwise re-trigger no longer match, so nothing double-logs or re-raises.Transceiver auto-selection: when
enable_pipelined_transferis set and no explicittransceiver_runtimeis given,resolve_cache_transceiver_configselectsPYTHONand logs a warning. Settingtransceiver_runtime='CPP'explicitly, or using a backend that resolves to something other than NIXL, raisesValueError.The Mamba/hybrid gate in
create_kv_cache_transceiverrejectsenable_pipelined_transferwheneverisinstance(kv_cache_manager, MambaHybridCacheManager)or a separatemamba_cache_managerwas passed in.MambaHybridCacheManageris the shared base class for all three hybrid managers —CppMambaHybridCacheManager,MambaHybridCacheManagerV2, andMixedMambaHybridCacheManager— so the isinstance check alone covers all of them; themamba_cache_manager is not Nonehalf is belt-and-suspenders for callers that pass the Mamba pool as a separate object. A normal transfer copies recurrent state once after prefill has produced its final value. Pipelined transfer would attach the samemamba_state_indexto every chunk while prefill continues mutating that slot; because the writes are asynchronous, a slice has no stable per-chunk recurrent-state snapshot. The implementation therefore fails during transceiver creation instead of allowing a configuration that could transfer an intermediate or inconsistently observed state.Two notes on the validation added in this PR:
extract_disagg_cfgcalls_validate_disagg_config), so a mismatchedschedule_stylefails before any worker starts rather than on the first request.parse_disagg_config_filealso takesschedule_style_overrideso the--schedule_styleCLI flag participates in the same validation instead of being applied afterward._validate_requestrejects only a request that carriespy_disaggregated_paramswith a non-generation-first style. A request with no disaggregated params (an aggregate request hitting the same executor, e.g. during warmup) is allowed through.Architecture
Sender-side chunking, monolithic receiver
Chunking is entirely a sender-side concept. The context server splits its source blocks into N chunks and issues N writes; the generation server posts a single receive covering the whole prompt and never learns how many chunks arrived.
flowchart LR subgraph ctx [Context server] exec[PyExecutor._send_kv_async] --> tcv[respond_and_send_async] tcv --> bld[_build_prefill_chunk] bld --> tx["TxSession, one per request"] tx --> t0["KVSendTask slice_id 0"] tx --> t1["KVSendTask slice_id 1"] tx --> tn["KVSendTask slice_id N-1, is_last_slice"] end subgraph gen [Generation server] rx["RxSession, one per request"] --> r0["KVRecvTask slice_id 0, whole prompt"] end t0 -->|RDMA write| r0 t1 -->|RDMA write| r0 tn -->|"RDMA write, is_last"| r0This asymmetry is what keeps the change contained:
request_and_receive_asyncon the generation side is unchanged, and the receiver completes when it has seenexpected_transfersresults carryingis_last_slice.KVSlice
KVSlice(tensorrt_llm/_torch/disaggregation/base/transfer.py) previously described one whole request. It now describes one chunk of one request:is_last_slicewas a latent field that was alwaysTrue. It is nowFalsefor intermediate chunks, and it is the signal that drives finalization on both sides.token_rangechanges meaning. It had exactly one consumer: the sender readtoken_range.endto recover the request's block spanceil(end / tpb), the anchor for all the suffix arithmetic..startwas never read, since_create_kv_slicealways produced[0, prompt_len). It now carries one chunk's window instead, and its presence is what makes a slice a chunk — a monolithic transfer leaves itNoneand keeps its whole-request addressing untouched, including the packed beam layout, which the chunked path is not written for and which_build_prefill_chunkrejects viabeam_width == 1..enddid are now answered separately, because a non-final chunk's end is neither of them. The request's block span, which the destination projection needs, isceil(prompt_len / tpb), rederived by the sender from the session'sprompt_len. SWA's stale-block boundary, which also needsprompt_len, reads the same field.An intermediate revision expressed all of this in block coordinates — a
ChunkCoords(block_offset, block_count)field for the window plus atotal_blocksfield for the span. Both were dropped.total_blockswas a function ofprompt_lenalone, so carrying it let the two sides disagree about a value neither of them decides; and the window is a genuine range over the request's tokens, soTokenRangenames it without inventing a second coordinate system for slices to speak. The window is still decided in block space (see "Deriving the chunk"), which is why both of its bounds are multiples oftokens_per_blockand the sender asserts that before dividing them back out.One invariant carries more weight now that the span is rederived rather than transmitted:
_create_kv_slicemust produceceil(prompt_len / tpb)blocks and must not include thenum_extra_kv_tokensslots speculative decoding reserves, since an extra block would shift every per-layer token start. That was previously expressed astoken_range.end == prompt_len;TestCreateKvSliceBlockSpanasserts it on the block list directly, which is the thing the sender actually consumes.SessionArgsBase.prompt_len(and theTxSession/RxSession/KVSendTaskconstructors) changed fromOptional[int]to required. SWA needs the request'sprompt_lento compute the stale-block boundary:Previously the slice's own extent was an acceptable stand-in for
prompt_len, because a slice was the whole request. A chunk stops at its own boundary, so using it would place the sliding window in the wrong place for every chunk but the last.TxSession and KVSendTask
TxSessionalready held a list of KV tasks; the list simply now has more than one entry.TxSession.sendassignsslice_id = len(self.kv_tasks), so a chunk's id is its arrival order.Session status aggregates over all tasks:
So the session is
KV_TRANSFERREDonly once every chunk has landed,ERRORif any chunk failed, andTRANSFERRINGwhile any chunk is mid-write.wait_completewaits on every task, andhas_transferring_tasks()(used bycancel_request) reports whether any chunk is mid-write.Slice ordering. With N slices per session there are now two producers that can enqueue writes for the same peer:
send()on the executor thread, andSender._respond_with_kvreplaying already-created slices when a peer registers late.TxSession.dispatch_lockspans both the snapshot and the enqueue loop in each path, because the receiver completes on theis_last_sliceresult without checking that earlier slices landed — a newer slice reaching a peer's queue ahead of an older one would let the generation server start decoding on incomplete KV.Executor loop integration
sequenceDiagram participant Exec as PyExecutor participant Sampler as TorchSampler participant Tcv as KvCacheTransceiverV2 participant Tx as TxSession participant Worker as Sender worker thread participant Rx as RxSession on gen loop intermediate prefill chunk Exec->>Exec: _forward_step(scheduled_batch) Exec->>Sampler: _update_requests(sample_state) Sampler-->>Exec: sampler_event.synchronize() Exec->>Tcv: respond_and_send_async(req) Tcv->>Tcv: _build_prefill_chunk(req) Tcv->>Tx: send(slice) with is_last_slice False Tx->>Worker: dispatch KVSendTask slice_id i Worker->>Rx: RDMA write then KV_AGENT_RESULT end Note over Exec: final chunk: is_context_finished or is_finished_due_to_length Exec->>Exec: release_index_slot(req) Exec->>Exec: async_transfer_manager.start_transfer(req) Exec->>Tcv: respond_and_send_async(req) Tcv->>Tx: send(slice) with is_last_slice True Tcv->>Tcv: _finalize_send: pack and send aux, set ContextPhaseParams Tcv->>Exec: req.state = DISAGG_CONTEXT_TRANS_IN_PROGRESSBoth branches live in
PyExecutor._send_kv_async, which runs after each forward step:Points worth calling out:
TorchSampler._update_requestsalready callsstate.sampler_event.synchronize()before_send_kv_asyncruns, so the chunk's KV writes are complete on the device by the time the slice is dispatched. An earlier revision carried acuda_eventonKVSlicefor this; it was removed as redundant.respond_and_send_asynchandles both cases. It creates or reuses theTxSessionvia_get_or_create_send_session(returning early if that returnsNone, i.e. the session was already retired), builds a chunk with_build_prefill_chunkwhen pipelining is on, and only calls_finalize_sendand setsDISAGG_CONTEXT_TRANS_IN_PROGRESSwhenslice.is_last_sliceis true. It also returns early when_build_prefill_chunkreturnsNone, which means the scheduler's chunk completed no whole block and there is nothing to send yet.start_transfercommits the request's blocks to the reuse tree and pins them; it must run before the last slice is sent, because sending the last slice is what starts the request's transition toward completion.has_retired_send_session(req)is checked before theis_context_finished/is_finished_due_to_lengthsplit, so a request whose send session was already torn down (see "Retired send sessions andDISAGG_TRANS_ERROR" below) never reaches eitherrespond_and_send_asynccall — it is failed andcontinues past both.cancel_pending_ids(a snapshot ofself.canceled_req_idstaken once per call) only guards theelif— the intermediate-chunk send. The final-chunkifhas nocancel_pending_idscheck at all; it fires whenever the forward pass says the request's context is done, cancellation-pending or not (a fully-cancelled request is already filtered out by the outernot req.is_finished_due_to_cancellation). Once a request reaches its last chunk,start_transfer/respond_and_send_asyncfinalize the transfer unconditionally rather than leaving it half-sent; only an earlier, still-in-flight chunk gets suppressed by a pending cancel.Chunk projection
The hard part of sending a chunk is that "chunk" is defined in one coordinate space (global block position within the prompt) while every block list involved is a resident suffix of some other range. Sliding-window layer groups, prefix reuse, and incremental allocation during prefill all shorten a list from the front. Indexing such a list with a raw global offset yields the wrong blocks.
project_blocks_to_global_chunk(base/transfer.py) resolves this by intersecting ranges rather than indexing:A list that does not reach the chunk at all returns empty rather than raising or silently sending the wrong blocks.
Deriving the chunk
_build_prefill_chunk(transceiver.py) turns the scheduler's token chunk into block coordinates:total_blocksis alwaysceil(prompt_len / tpb)— the full prompt span, which is what the destination side is allocated for and what the sender rederives from the session'sprompt_len. The chunk bounds are clamped to it. Passingresident_block_end=chunk_endalso normalizes the two cache-manager allocation models: V1's full-prompt reservation is capped at the computed boundary, while V2's incrementally allocated list is already bounded there.This is the only place chunk geometry is decided, and the resulting window travels to the sender worker as a block-aligned
TokenRange. Deciding it once matters because the window is not a function of the scheduler's token bounds alone: extending the first chunk back to block 0 depends onprepopulated_prompt_len, the clamp depends onprompt_len, and the rounding rule below depends ontokens_per_block. The sender reads that answer rather than re-deriving it; the only thing it pays for expressing the window in token space is one alignment assert on arrival before it divides the bounds back out.Unaligned chunk boundaries round down
Nothing requires the scheduler to cut chunks on block boundaries, and the two schedulers disagree about whether it does. V1 makes it true by construction:
setPrepopulatedPromptLenshrinks the first chunk soprepopulatedPromptLen + chunkSizefloors to a block boundary, andTLLM_CHECKs the result to prevent cache fragmentation. The V2 Python scheduler rounds the chunk size tochunk_unit_sizebut not the absolute end, so a partial-block reuse hit offsets every boundary that follows — a gemma3 V2 run withtokens_per_block=32produced a non-final chunk ending at token 259.An earlier revision asserted alignment here and failed on exactly that. The transfer layer should not dictate scheduler chunk policy, so the rule is instead:
Both bounds floor, so consecutive chunks tile block space exactly. A block holding an unaligned boundary is not sent by the earlier chunk, whose tokens stop partway into it, and is sent whole by the next one, whose start floors into the same block. Every block is transferred exactly once, and only after the forward pass has filled it.
flowchart LR subgraph chunkA [Chunk A, ends at token 259] a1["blocks 0..7<br/>fully computed, sent"] end subgraph chunkB [Chunk B, starts at token 259] b1["block 8<br/>partial in A, completed here"] b2["blocks 9 onward"] end a1 -->|"no overlap"| b1Three properties make this work:
ceil(prompt_len / tpb)is the clamp itself._build_prefill_chunkreturnsNoneandrespond_and_send_asyncreturns without touching the session; the next chunk's start floors to the same place, so the deferred blocks travel with it. The final chunk cannot hit this — its end isceil(prompt_len / tpb), always past its own start — sois_last_slicealways reaches the receiver and no request can wait on a chunk that was skipped.floor(chunk_end_pos / tpb)is at most the index just past the last block the forward pass filled, soresident_block_end = chunk_endstays within the source list under both allocation models.Rounding up was the original rule, and it worked, but it put the boundary block in both chunks: the earlier chunk sent it with a stale tail past the boundary, and the next chunk rewrote it whole. That left correctness resting on write ordering —
Sender._enqueuerouting a peer's writes to one worker thread, which delivers them serially, so the computed copy always lands last — and briefly left a partially valid block in the destination. Flooring removes both, at the cost of deferring at most one block per boundary by one chunk. Ordering still matters foris_last_slice(see "Slice ordering"); it no longer matters for block contents.The first slice always starts at block 0
The scheduler's chunk sequence does not necessarily cover the whole prompt. On a context-side prefix-reuse hit,
setPrepopulatedPromptLenadvancescontext_current_positiontoprepopulated_prompt_lenbefore the first chunk is cut, sopy_last_context_chunkstarts atP = prepopulated_prompt_lenand no chunk ever spans[0, P). Those blocks are resident and valid —_create_kv_sliceforcescached_per_lg = [0] * len(layer_groups)on the context side, so the base slice holds them — but the projection would drop them and the generation server would decode over whatever those pages held:So the first slice extends its start back to block 0, carrying the reused prefix along with the first computed chunk. The monolithic path was never affected: it sends the whole base slice.
prepopulated_prompt_lenis written exactly once per request and chunk starts increase monotonically, so only the first chunk satisfies the equality; with no reuseP == 0and the rule is a no-op.req.is_first_context_chunkcannot substitute for it, because it comparescontext_current_positionagainstprepopulated_prompt_lenand the cursor has already advanced by the time_send_kv_asyncruns — the recorded chunk start is the pre-advance value.Both projections still hold with
chunk_start = 0. On the source,_create_kv_slice(..., resident_block_end=chunk_end)first removes uncomputed V1 pages and trims VSWA groups to the overlap between the computed prefix and the final prompt window. On the destination (resident_block_end = total_blocks) the overlap is[max(0, G_b), chunk_end), which no-ops whatever the generation server already has. And when the whole prompt fits in one chunk alongside a reuse hit, the slice degenerates to a chunk spanning[0, total_blocks):suffix_end_blocksequalstotal_blocksand the destination projection is the identity, so the write is addressed byte-for-byte like a non-pipelined transfer.test_whole_prompt_chunk_addresses_like_a_monolithic_slicepins that by building the same slice with and without atoken_rangeand comparing the resultingWriteMeta.This gap was invisible in CI because it is masked whenever the generation server has the same prefix cached: its
RecvReqInfoblock list is trimmed bycache_skip,dst_startrises above the gap, and_align_kv_blockswould have trimmed those blocks anyway. The bug bites only when the two cache states diverge — generation-side reuse off, a cold generation cache, different eviction pressure, or different DP routing.Two projections with different
resident_block_endThe same helper is called on both sides of the transfer with a deliberately different end bound:
resident_block_end_build_prefill_chunkchunk_endchunk_end, while V2 is already incremental._build_kv_write_metatotal_blocksUsing
total_blocksfor the source was the bug fixed inFix resident chunk end calculation(0628b97): it placedresident_starttoo far left, so every intermediate chunk selected blocks from the wrong position in a partially allocated source list.VSWA chunk projection: V1 cache manager
Consider a 16-block prompt, a final 4-block VSWA suffix
[12,16), and an intermediate chunk[11,13). Intervals are end-exclusive, so this chunk has computed logical blocks 11 and 12 and overlaps the final window only at block 12. Page namespNbelow are illustrative physical pages holding logical blockN.V1 reserves physical pages for the entire prompt before all chunks have been computed. The source must therefore be capped before taking the VSWA suffix:
flowchart LR raw["V1 raw pages<br/>p0 ... p15<br/>full prompt reserved"] cap["Cap at chunk_end = 13<br/>p0 ... p12"] trim["Trim below final stale_end = 12<br/>p12"] project["Project chunk [11,13)<br/>p12"] dst["Write destination<br/>logical block 12"] raw --> cap --> trim --> project --> dstWithout the cap, trimming first would produce
p12 p13 p14 p15. Reinterpreting that list as a suffix ending at block 13 could select future pages such asp14and silently pair one with destination block 12.VSWA chunk projection: V2 cache manager
V2 grows and evicts incrementally. At
chunk_end = 13, its live 4-block VSWA range is[9,13), so it exposesp9 p10 p11 p12and has no future pages. It still needs the same final-window trim; otherwise the sender can pair an earlier computed page with destination block 12.flowchart LR raw["V2 raw live pages<br/>p9 p10 p11 p12<br/>range [9,13)"] cap["Cap at chunk_end = 13<br/>no change"] trim["Trim below final stale_end = 12<br/>p12"] project["Project chunk [11,13)<br/>p12"] dst["Write destination<br/>logical block 12"] raw --> cap --> trim --> project --> dstAfter normalization, both managers send exactly one computed source page for the same logical position the receiver requested:
Sender-worker side
_build_kv_write_meta(native/transfer.py) applies the destination projection and then reduces everything to a token-space alignment:slice.token_rangeselects the path. It is set only by_build_prefill_chunk, so the sender reads the chunk window rather than inferring one, and a monolithic transfer — including a packed beam layout, which the chunked path does not model — cannot accidentally be routed through it.total_blocks = ceil(task._prompt_len / tpb)is rederived here, since it is a property of the request rather than of the slice.suffix_end_blocksistoken_range.end // tpbwhen chunked andtotal_blocksotherwise; both bounds are asserted block-aligned before the division, because the producer decided them in block space and a partial block arriving on the wire would mean a bug upstream. Per-layer token starts follow from(suffix_end_blocks - n_blocks) * tpb.req_info.dst_start_token(generation-side prefix reuse) and by the SWAstale_end, and_align_kv_blockstrims both arrays to the shared token overlap. That single overlap computation covers all four cases: no prefix cache, context-side prefix cache, generation-side prefix cache, and a chunk that falls entirely inside the generation server's already-cached prefix (which produces an empty transfer).Wire protocol: the result frame's slice id
The
KV_AGENT_RESULTframe carries a single slice id, and the receiver uses it purely as an index into its own task list — so the value has to be the receiver's task index. The sender filled it withtask.slice_id, its own task index, which was the same number only as long as a session had one task. With N chunks, chunk i would have addressed the receiver's task i, and the receiver only ever posts one. The field was also namedslice_idwith no side attached, and the parameter the receiver unpacked it into was literally calledsender_slice_id, which is what let the confusion survive review.The fix is one line at the point the frame is built:
sequenceDiagram participant TxSession participant SenderWorker participant RxSession TxSession->>SenderWorker: KVSendTask.slice_id = chunk index 0..N-1 Note over SenderWorker: WriteMeta.slice_id = req_info.slice_id or 0<br/>the sender's chunk stays reachable via WriteMeta.task SenderWorker->>RxSession: KV_AGENT_RESULT carries the receiver's slice id, is_last, status Note over RxSession: index _kv_tasks[slice_id]WriteMetakeeps its single pre-existingslice_id: Optional[int]field; only its source changed._build_kv_write_metasetsslice_id=req_info.slice_id if req_info.slice_id is not None else 0— the peer's task index, echoed back fromRecvReqInfo— instead oftask.slice_id. An intermediate revision split the field intosender_slice_id/receiver_slice_idso a chunk-level RDMA failure could still name the sender's chunk in the sender's own logs. That was reverted:_deliver_kv_to_agentresolves the send task fromwrite_meta.task, which the struct already carries, instead of indexing the session by chunk, so multi-chunk pipelining is correct with one field. The cost is that the sender's per-slice log lines no longer identify which chunk they belong to._KV_RESULT_PREFIX(struct.Struct("<qqq?Bq")) is unchanged in shape and content —instance_rank, unique_rid, receiver_slice_id, is_last, status, transfer_size— it already carriedtransfer_sizebefore this change. The only edit is renaming the third field fromsender_slice_idtoreceiver_slice_idin_make_kv_result_msg,_send_failed_result_to_receiver, and_process_kv_agent_result, so the name matches what the value has always had to mean. This is a ctx/gen wire format with no version negotiation — the receiver unpacks whatever arrives against its compiled-in struct — which is why the fix stays inside the existing field rather than widening the frame.RxSession.process_kv_agent_result(peer_rank, receiver_slice_id, is_last_slice, status, ...)indexes_kv_tasks[receiver_slice_id]and asserts it is in range first, so a sender/receiver slice-count mismatch fails loudly instead of resolving to the wrong task. The FAILED-detail log points at the sender's own log ("reported by remote agent; see sender-side log for nixl_status") rather than trying to name the sender's chunk locally.For a single-chunk session the old and new values coincide, which is why the monolithic path never saw this and why the change is invisible to a monolithic receiver:
receiver_slice_idis0in every current deployment.KV transfer state
Before pipelined transfer,
DISAGG_CONTEXT_TRANS_IN_PROGRESSwas a faithful proxy for "the fabric may be reading this request's KV pages". It no longer is: after the first non-finalsession.send(slice), chunks are in flight while the request is still inCONTEXT_INIT.Two failures followed from the gate keying entirely off request state. Cancelling a request mid-prefill took the "nothing to cancel" path in
_try_cancel_request, so_handle_responsesterminated it andfree_resourcesreleased pages aKVSendTaskmight still be reading, while theTxSessionleaked in_send_sessionsand the receiver was never notified. Separately,respond_and_send_asyncstarts the timeout clock on the first chunk, but_check_kv_transfer_timeoutonly walkedasync_transfer_manager.requests_in_transfer()— which the request does not enter untilstart_transferon the last chunk — so the entire pipelined phase was unmonitored while its clock ran.The fix is two orthogonal dimensions instead of one overloaded state.
LlmRequestStatekeeps meaning "compute and response phase". Transfer activity is answered by the component that actually owns the resources — the transceiver's session maps — so it cannot drift out of sync the way a mirrored request field would.flowchart LR subgraph phase [Request phase - LlmRequestState] ctxInit[CONTEXT_INIT] --> transProg[DISAGG_CONTEXT_TRANS_IN_PROGRESS] transProg --> complete[DISAGG_CONTEXT_COMPLETE] end subgraph transfer [Transfer ownership - transceiver session maps] nosession[no session] --> active[session in _send_sessions] active --> torn[session closed and deleted] end ctxInit -.->|first non-final send| active torn -.->|safe to free KV| completeSession membership is the right record:
_get_or_create_send_sessioninserts before the firstsend, and every teardown path (cancel_request, the completed and cancelled loops incheck_context_transfer_status,_close_failed_sessions) deletes it.Concretely:
KvCacheTransceiver.has_inflight_transfer(req)is non-abstract and defaults toFalse, which leavesBindKvCacheTransceiveruntouched — the C++ transceiver has no pipelining, so state and transfer activity coincide there.KvCacheTransceiverV2implements it from session membership;get_unique_ridreturningNonefor a non-disagg request naturally yieldsFalse._is_request_in_transmissionreturnsTruewhen either the state says so orhas_inflight_transfer(request)does. Its only caller is_try_cancel_request, so the blast radius is contained: a mid-prefill cancel now routes throughKvCacheTransceiverV2.cancel_request, which cancels theTxSession, notifies the receiver, and returnsFalsewhile any task isTRANSFERRING. The existing retry in_handle_canceled_requeststhen holds the KV pages until the write drains — the same behavior the monolithic path already relies on._send_kv_asyncsnapshotscanceled_req_idsintocancel_pending_idsonce per call, but the two branches are gated asymmetrically, not by one sharedcontinue. Theelif(intermediate-chunk) branch checksreq.py_request_id if not req.is_child else req.parent_request_id) not in cancel_pending_ids: a session whose cancel is pending would otherwise be fed another chunk and produce a spuriousFAILEDresult to the receiver. Theif(final-chunk) branch —is_context_finished or is_finished_due_to_length— has nocancel_pending_idscheck; once a request reaches its last chunk,start_transferandrespond_and_send_asyncrun unconditionally, because a fully-cancelled request is already excluded upstream by the outernot req.is_finished_due_to_cancellation, and letting the last chunk finalize normally is simpler than adding a new half-sent state. What both branches share is thehas_retired_send_session(req)check ahead of theif/elifsplit (see below), which is the one place a singlecontinuereally does skip both._check_kv_transfer_timeoutscansrequests_in_transfer(), which a context request enters on its final chunk, andrespond_and_send_asyncoverwritespy_kv_transfer_start_timeon every chunk, so the clock it measures is the last chunk's. Intermediate chunks are deliberately not swept:py_kv_transfer_timed_outcan only be set for a request the transfer manager already knows about, so_check_disagg_ctx_cache_transfer_statushas nothing to act on while prefill is still running.Retired send sessions and
DISAGG_TRANS_ERRORA send session can be torn down before its last chunk goes out — cancellation, an RDMA failure mid-chunk, or a peer-registration timeout.
TxSession.close()also callsSender.clear_session, which drops the peer'sRecvReqInfo; the generation server never re-sends it, so a session re-created after that point has no peer to write to and every task it creates would sit inINITforever. The fix bars the re-creation instead of allowing it to silently hang.LlmRequestgainedpy_kv_send_session_retired(Falseby default), set whenever a send session is torn down:Sender._retire_send_session(used by the cancel and completion paths incheck_context_transfer_statusand bycancel_request) andSender._close_failed_sessions(..., mark_retired=True)(the failure path) both set it._get_or_create_send_sessionchecks it before creating a session for aridit doesn't already have: if set, it logs a warning, setsreq.state = LlmRequestState.DISAGG_TRANS_ERROR, and returnsNoneinstead of creating a session with no peer.KvCacheTransceiverV2.has_retired_send_session(req)(transceiver.py) reportsreq.py_kv_send_session_retired and get_unique_rid(req) not in self._send_sessions— retired and not already re-registered, so a request whose session completed and was retired normally (every successful transfer retires its session onceKV_TRANSFERRED) does not spuriously read as failed once it moves on.KvCacheTransceiver.has_retired_send_sessiondefaults toFalseon the base class, soBindKvCacheTransceiveris unaffected._send_kv_asyncchecksself.kv_cache_transceiver.has_retired_send_session(req)first, ahead of both the final-chunk and intermediate-chunk branches: if true, it setsreq.state = LlmRequestState.DISAGG_TRANS_ERRORandcontinues past both, explicitly beforestart_transfercould otherwise pin blocks that onlyend_transfercan release.LlmRequestState.DISAGG_TRANS_ERRORitself is not new — the C++ core already defines it,py_executor.pyalready had several pre-existing consumers of it (e.g._end_transfer_and_maybe_terminate,_get_disagg_reqs_in_error_state, the generation-side error-state sweeps), andtransceiver.py's_close_failed_sessionsalready set it on the pre-pipelining RDMA-failure path. This PR adds two new producers that both flow from session retirement:_get_or_create_send_session(fails the request instead of re-creating a session with no peer) and thehas_retired_send_sessioncheck in_send_kv_async. Both reuse the existing state and existing downstream handling rather than inventing a new one, so a request whose peer registration is gone is failed and drained through the same machinery as any other disaggregated transfer error, instead of stalling until a timeout.Change inventory by file
Non-test files touched by this branch, for evaluating which changes are load-bearing vs. removable noise. Sizes are cumulative insertions/deletions against the merge base (
git diff <base> --numstat). "Core" = the feature does not work without it. "Supporting infra" = validation/config plumbing/error handling that makes the feature robust but isn't the transfer mechanism itself. "Adjacent" = could plausibly be split into its own PR.tensorrt_llm/_torch/disaggregation/base/transfer.pyproject_blocks_to_global_chunk()(range-intersection projector), repurposesKVSlice.token_rangefrom "the whole request" to "this chunk's block-aligned window" (and relaxesTokenRangeto allow an empty range), and makesSessionArgsBase.prompt_len: intrequired (wasOptional[int]).token_rangemarks a slice as a chunk vs. whole-request and carries its window,prompt_lenon the session is where the request's own extent now lives, andproject_blocks_to_global_chunkis the primitive both sender and receiver use to map a global chunk window onto a resident-suffix block list (needed for SWA/prefix-reuse/incremental allocation).tensorrt_llm/_torch/disaggregation/native/transfer.pyWriteMeta.slice_idfromreq_info.slice_id(the peer's task index) instead of the sender's chunk index and renames the wire field toreceiver_slice_idin_KV_RESULT_PREFIX/_make_kv_result_msg/process_kv_agent_result, adds_send_kv_result_to_receiver()helper, adds chunk-aware projection in_build_kv_write_meta(rederivestotal_blocksfromprompt_len, takessuffix_end_blocksfromtask._slice.token_range), makesTxSession/RxSession/KVSendTasktake requiredprompt_len, addsTxSession.dispatch_lockaroundsend()/send_aux()/_respond_with_kvreplay, and extendsTxSession.statusto aggregateERROR/TRANSFERRINGover allkv_tasks.dispatch_lock) so a late chunk can't race an in-flight one and violateis_last_slicesemantics.tensorrt_llm/_torch/disaggregation/transceiver.py_build_prefill_chunk()(derives the block-alignedTokenRangefromreq.py_last_context_chunk/prepopulated_prompt_len/tokens_per_block, returningNonefor a chunk that completes no block), extends_create_kv_slice(req, resident_block_end=...), addspipeline_transfer_enabledproperty,has_inflight_transfer(),has_retired_send_session(),_retire_send_session(), rewrites_close_failed_sessionsto takemark_retired, and updatesrespond_and_send_asyncto skip an empty chunk, send intermediate chunks, or finalize only onis_last_slice.DISAGG_TRANS_ERRORon stale re-creation), and whererespond_and_send_asyncgains the branch that ships an intermediate vs. final chunk.tensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.pyresolve_cache_transceiver_config()(collapses"auto", auto-selectsPYTHONruntime whenenable_pipelined_transferis set, rejectsCPP+pipelined, rejects non-NIXL backend, rejectskv_cache_bounce_size_mb>0+pipelined), calls it at the top ofcreate_kv_cache_transceiver, adds Mamba/hybrid-cache andpipeline_parallel_size==1rejection checks, and adds non-abstractpipeline_transfer_enabled/has_inflight_transfer/has_retired_send_sessiondefaults to theKvCacheTransceiverbase class (soBindKvCacheTransceiveris unaffected).tensorrt_llm/_torch/pyexecutor/llm_request.pyLlmRequest.py_kv_send_session_retired = Falsefield._get_or_create_send_session/has_retired_send_sessionto detect and fail requests whose send session was torn down before its last chunk instead of silently re-creating a session with no peer.tensorrt_llm/_torch/pyexecutor/py_executor.py_validate_requestchecks (beam_width==1,schedule_style==generation_firstwhen pipelining), rewrites the_send_kv_asynccontext-request loop to checkhas_retired_send_sessionfirst, then branch on final-chunk (is_context_finished/is_finished_due_to_length) vs. intermediate-chunk (pipeline_transfer_enabled+ not cancel-pending) sends, and extends_is_request_in_transmissionto also consultkv_cache_transceiver.has_inflight_transfer(request).LlmRequestState) and per-request validation of the two hard preconditions.tensorrt_llm/_torch/pyexecutor/py_executor_creator.pyresolve_cache_transceiver_config(cache_transceiver_config)early increate_py_executor(before KV cache pool/manager selection), and raisesValueErrorifenable_pipelined_transferis set withoutenable_chunked_prefill.get_kv_cache_manager_cls, fabric-memory env-var default) readtransceiver_runtime, and enforces the chunked-prefill precondition (chunk boundaries come from the chunked-prefill scheduler).tensorrt_llm/commands/serve.pydisaggregated()to callparse_disagg_config_file(config_file, schedule_style_override=schedule_style)instead of parsing then overwritingdisagg_cfg.schedule_styleafter the fact.--schedule_styleCLI flag through the same validation path (_validate_disagg_config) as the YAML-declared value, so a mismatchedschedule_stylewithenable_pipelined_transfer=Trueis caught at startup regardless of whether it came from the file or the CLI.tensorrt_llm/llmapi/disagg_utils.py_validate_disagg_config()(checksschedule_styleis valid and rejectsenable_pipelined_transfer=Truewith a non-generation_firstschedule_style), calls it fromextract_disagg_cfg, addsschedule_style_overrideparam toparse_disagg_config_file, and adds an empty-YAML guard.schedule_style: generation_firstrequirement — the pipelining precondition that the generation server must have registered destination blocks before prefill compute finishes.tensorrt_llm/llmapi/llm_args.pyCacheTransceiverConfig.enable_pipelined_transfer: bool = Falsefield, with a comment in_to_pybind()noting it has no C++ counterpart and isn't forwarded.tensorrt_llm/usage/llm_args_golden_manifest.jsoncache_transceiver_config.enable_pipelined_transferentry (bool, no allowed values/converter).generate_llm_args_golden_manifest.py) whenever anLlmArgs-nested field is added — keeps the golden manifest in sync withllm_args.py.None of the above looks like unrelated cleanup that could plausibly split into its own PR — even the
serve.pyanddisagg_utils.pychanges, which read like general validation/robustness plumbing, exist specifically to enforce theschedule_style=generation_firstprecondition this feature requires, andcommands/serve.py's one-line refactor exists only so the CLI flag goes through that new validation path. The closest thing to a separable line item is the result-frame rename (sender_slice_id→receiver_slice_id) insidenative/transfer.py, which is a pure rename with no behavior change; it is kept here because it documents the value the accompanying one-line fix now puts in that field, and splitting the rename from the fix would leave one of the two halves looking arbitrary.