[TRTLLM-12499][feat] Pipelined KVCache transfer for disaggregated serving in Python Cache Transceiver - #15727
Conversation
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 💡 1⚔️ Resolve merge conflicts 💡
🧪 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
| # does not support UCX, MPI, or MOONCAKE). | ||
| runtime = cache_transceiver_config.transceiver_runtime | ||
| use_python = runtime == "PYTHON" | ||
| if (runtime is None and cache_transceiver_config.enable_pipelined_transfer): |
There was a problem hiding this comment.
A post-rebase ordering question: main's KvCacheCreator._maybe_enable_fabric_memory_for_python_transceiver() (#16832) runs before KV-pool allocation and enables the fabric-memory default only when the config says transceiver_runtime == "PYTHON". The default "auto" (e.g. Llama) resolves to None, and the auto-selection here only flips the local use_python without persisting it — after the pool is already allocated. So enable_pipelined_transfer + default runtime would get a V1 C++ pool without fabric memory, affecting MNNVL transfers. The integration test sets "PYTHON" explicitly, so this path isn't covered.
Could "pipelined ⇒ PYTHON" be resolved and persisted before pool routing — via_resolve_transceiver_runtime_auto on the normal path, with a fallback or fail-fast for paths like AutoDeploy that skip it?
fredricz-20070104
left a comment
There was a problem hiding this comment.
Review summary - CONCERNS
Verdict: Cannot merge as-is — the branch is in conflict (mergeable_state=dirty) and has two outstanding CHANGES_REQUESTED on the head commit while the PR is still WIP (benchmarking). The feature logic and tests are largely solid, but there is one correctness concern worth confirming before merge.
Concerns
-
[MAJOR]
native/transfer.py(~_deliver_kv_to_agent, lines 488-528) - cuda_event sync happens while task is still INIT- What is wrong: The worker waits on
task._slice.cuda_event.synchronize()before thesession.lock-heldINIT→TRANSFERRINGtransition. (The sync line is just outside this diff, so please confirm the current ordering — coderabbitai flagged the same at 488-507.) - How it fails: While the worker is blocked in
synchronize(), the task is stillINIT. A concurrentcancel_request()iterateskv_tasks, sees noTRANSFERRINGtask, and frees/reuses the request's KV pages. When the event completes the worker issues the RDMA into freed pages → use-after-free / corrupted KV on the wire. - Suggested fix: Perform the
INIT→TRANSFERRINGtransition (undersession.lock, with the CANCELLED/ERROR abort check) beforecuda_event.synchronize(), and return early on the abort path.
- What is wrong: The worker waits on
-
[MAJOR] Merge blocker — dirty branch + change requests.
mergeable=false,mergeable_state=dirty. Rebase onto main, resolve conflicts, and address the CHANGES_REQUESTED fromShixiaowei02andnv-xtfon commit4c6c035. PR description still reads 'Status: Benchmarking performance impact'.
Minor notes (non-blocking)
tests/unittest/disaggregated/test_kv_transfer.py-test_transfer_worker_chunkedis missing theif not torch.cuda.is_available(): pytest.skip(...)guard thattest_transfer_worker_pipelinedhas; it will error rather than skip on a CUDA-less runner._build_prefill_chunkassumes chunk boundaries are KV-block-aligned (floor start / ceil end). This is guaranteed by chunked-prefill's block-multiple invariant and validated later byderive_chunk_block_coords, but consider an explicit assert in_build_prefill_chunkto fail fast if a non-aligned boundary ever reaches it.
QA view
- Test coverage: adequate — new unit tests (
test_chunked_transfer.py, additions totest_kv_transfer.py) cover projection math, TxSession/RxSession state machines, chunked & pipelined send paths, and the requirement validators; a new integration accuracy test is added. Uncovered: the concurrent cancel-during-cuda_event-sync race in finding #1. - SM coverage: architecture-independent (pure Python/RDMA transfer logic, no
get_sm_version/arch guards). Integration test runs Hopper+ (skip_pre_hopper) and is listed on B200. - Test code: missing CUDA skip guard on
test_transfer_worker_chunked; otherwise clean (context-managed server launch, explicit cleanup). - Test time: significant — four new disaggregated GSM8K accuracy parametrizations on
l0_dgx_b200, each launching a ctx+gen server pair. - Needs
/qa-verify: yes — WIP multi-GPU feature, dirty branch, and the cancel/cuda_event race has no reproducing test; re-run the disagg accuracy suite after conflicts are resolved.
Possible new issues
- Non-block-aligned chunk boundaries could overlap/transfer partial blocks (mitigated by chunked-prefill's block-multiple invariant).
prompt_lenchanged fromOptional=Noneto required acrossSessionArgsBase/KVSendTask/TxSession/RxSession; external callers not shown may break._build_kv_write_metanow usestask._prompt_lenforslice_endinstead oftoken_range.end; recheck the speculative-decoding extra-draft-block path for a one-block start-offset shift.
What I could not verify
- The exact placement of
cuda_event.synchronize()relative to the lock transition (line lies just outside the diff). - All external callers of the constructors whose
prompt_lenbecame required. - Runtime accuracy/perf of the pipelined path (integration test not executed here).
Automated review by NVCortex Lite, run by @fredricz-20070104.
4c6c035 to
f39c7b3
Compare
|
@coderabbitai resume |
✅ Action performedReviews resumed. |
a530532 to
085a8d6
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
tensorrt_llm/llmapi/disagg_utils.py (1)
78-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAnnotate the public parser return type.
Add
-> DisaggServerConfigtoparse_disagg_config_file._validate_disagg_configis private, so a docstring is not required by the repository 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 `@tensorrt_llm/llmapi/disagg_utils.py` around lines 78 - 79, Update the public parse_disagg_config_file function signature to explicitly return DisaggServerConfig. Leave the private _validate_disagg_config function unchanged, including its lack of a docstring.Source: Coding guidelines
tensorrt_llm/_torch/disaggregation/native/transfer.py (1)
206-215: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAnnotate the rewritten
_make_kv_result_msgsignature.The signature was expanded in this change but stays unannotated. Add parameter and return types so the new sender/receiver ID contract is explicit at the call sites.
♻️ Proposed annotations
def _make_kv_result_msg( - instance_rank, - unique_rid, - sender_slice_id, - receiver_slice_id, - is_last_slice, - agent_result, - transfer_size=0, - tail=None, -): + instance_rank: int, + unique_rid: int, + sender_slice_id: int, + receiver_slice_id: int, + is_last_slice: bool, + agent_result: AgentResult, + transfer_size: int = 0, + tail: Optional[list[bytes]] = None, +) -> list:Based on coding guidelines: "Annotate every function, use
Nonefor procedures".🤖 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 206 - 215, Annotate the `_make_kv_result_msg` parameters and return value, including the sender and receiver slice ID types and the optional `tail` parameter. Use `None` as the return annotation if the function is a procedure, and follow the surrounding module’s established type aliases or conventions.Source: Coding guidelines
tensorrt_llm/_torch/disaggregation/transceiver.py (1)
571-597: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRoute send-side failure teardown through
_retire_send_session.
_close_failed_sessionsnow duplicates the teardown that_retire_send_sessionperforms, and it only does so whenmark_retired=True. The two paths also differ in robustness:_close_failed_sessionsusesdel reqs[rid]andreqs[rid].state, which raiseKeyErrorif_send_reqshas no entry, while_retire_send_sessionuses.pop(). Today_send_reqs[rid]is always populated by_build_prefill_chunkbefore any failure can be collected, so this is a maintainability concern rather than a live bug.Consider keeping
_close_failed_sessionsfor the receive path and calling_retire_send_sessionfor send-side failures, so one function owns sender retirement.🤖 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 571 - 597, The send-side failure path should use _retire_send_session as the single owner of sender teardown. Update callers handling send failures to invoke _retire_send_session for each failed request, while retaining _close_failed_sessions for receive-side cleanup; remove any duplicated send-session close, removal, or retirement handling from that path.
🤖 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 2014-2021: Update the assertion in the lock block before indexing
self._kv_tasks to require receiver_slice_id >= 0 as well as being less than
len(self._kv_tasks). Preserve the existing mismatch error context, then use the
validated receiver_slice_id to select the task.
In `@tensorrt_llm/_torch/pyexecutor/_util.py`:
- Around line 3000-3007: Update the call to create_kv_cache_transceiver in
create_py_executor to pass the effective chunked-prefill state (ctx_chunk_config
is not None or the resolved enable_chunked_context) instead of
llm_args.enable_chunked_prefill, ensuring the factory reflects the scheduler’s
actual configuration.
In `@tensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.py`:
- Around line 137-142: Update the warning in the enable_pipelined_transfer
handling to state that users must explicitly set transceiver_runtime="PYTHON" or
disable enable_pipelined_transfer; remove the incorrect guidance that
transceiver_runtime="CPP" disables pipelined transfer. Keep the automatic
cache_transceiver_config.transceiver_runtime assignment unchanged.
In `@tests/integration/defs/accuracy/test_disaggregated_serving.py`:
- Around line 1593-1595: Add the skip_pre_hopper decorator to the new test’s
decorator list alongside skip_less_device and the existing parameterization
decorators, matching the guards on TestGemma3_1BInstruct::test_auto_dtype and
test_kv_cache_v2_nixl_python.
In `@tests/integration/test_lists/test-db/l0_dgx_b200.yml`:
- Around line 21-26: Reduce the pre_merge entries under the “Disaggregated
Serving: Pipelined KV Transfer (multi-GPU)” section in l0_dgx_b200.yml by
retaining only representative Llama and Gemma combinations; move the
lower-priority node IDs to the post_merge tier instead of adding all five to
pre_merge.
---
Nitpick comments:
In `@tensorrt_llm/_torch/disaggregation/native/transfer.py`:
- Around line 206-215: Annotate the `_make_kv_result_msg` parameters and return
value, including the sender and receiver slice ID types and the optional `tail`
parameter. Use `None` as the return annotation if the function is a procedure,
and follow the surrounding module’s established type aliases or conventions.
In `@tensorrt_llm/_torch/disaggregation/transceiver.py`:
- Around line 571-597: The send-side failure path should use
_retire_send_session as the single owner of sender teardown. Update callers
handling send failures to invoke _retire_send_session for each failed request,
while retaining _close_failed_sessions for receive-side cleanup; remove any
duplicated send-session close, removal, or retirement handling from that path.
In `@tensorrt_llm/llmapi/disagg_utils.py`:
- Around line 78-79: Update the public parse_disagg_config_file function
signature to explicitly return DisaggServerConfig. Leave the private
_validate_disagg_config function unchanged, including its lack of a docstring.
🪄 Autofix
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: 9b760343-8b54-47b9-80fe-44654fe3da71
📒 Files selected for processing (21)
tensorrt_llm/_torch/auto_deploy/shim/ad_executor.pytensorrt_llm/_torch/disaggregation/base/transfer.pytensorrt_llm/_torch/disaggregation/native/transfer.pytensorrt_llm/_torch/disaggregation/transceiver.pytensorrt_llm/_torch/pyexecutor/_util.pytensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.pytensorrt_llm/_torch/pyexecutor/llm_request.pytensorrt_llm/_torch/pyexecutor/py_executor.pytensorrt_llm/_torch/pyexecutor/py_executor_creator.pytensorrt_llm/commands/serve.pytensorrt_llm/llmapi/disagg_utils.pytensorrt_llm/llmapi/llm_args.pytensorrt_llm/usage/llm_args_golden_manifest.jsontests/integration/defs/accuracy/test_disaggregated_serving.pytests/integration/test_lists/test-db/l0_dgx_b200.ymltests/unittest/_torch/executor/test_disagg_index_mapper_early_release.pytests/unittest/disaggregated/test_bounce.pytests/unittest/disaggregated/test_chunked_transfer.pytests/unittest/disaggregated/test_disagg_utils.pytests/unittest/disaggregated/test_kv_transfer.pytests/unittest/disaggregated/test_transceiver_bounded_polling.py
🚧 Files skipped from review as they are similar to previous changes (1)
- tensorrt_llm/llmapi/llm_args.py
| with self.lock: | ||
| self.kv_cache_size_bytes += transfer_size | ||
| assert sender_slice_id < len(self._kv_tasks), ( | ||
| f"Receiver got slice_id={sender_slice_id} from sender but only has " | ||
| f"{len(self._kv_tasks)} receive task(s) for request {self.request_id}. " | ||
| f"Sender/receiver slice count mismatch." | ||
| assert receiver_slice_id < len(self._kv_tasks), ( | ||
| f"Receiver got receiver_slice_id={receiver_slice_id} (sender_slice_id=" | ||
| f"{sender_slice_id}) but only has {len(self._kv_tasks)} receive task(s) " | ||
| f"for request {self.request_id}. Sender/receiver slice count mismatch." | ||
| ) | ||
| task = self._kv_tasks[sender_slice_id] | ||
| task = self._kv_tasks[receiver_slice_id] |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Check the lower bound of receiver_slice_id.
The assertion checks only the upper bound. A negative value indexes self._kv_tasks from the end and resolves the wrong task instead of failing. NO_SLICE_ID is -1 and is carried in the same frame, so a future sender-side mix-up of the two fields would corrupt completion silently rather than raise.
🛡️ Proposed guard
- assert receiver_slice_id < len(self._kv_tasks), (
+ assert 0 <= receiver_slice_id < len(self._kv_tasks), (
f"Receiver got receiver_slice_id={receiver_slice_id} (sender_slice_id="
f"{sender_slice_id}) but only has {len(self._kv_tasks)} receive task(s) "
f"for request {self.request_id}. Sender/receiver slice count mismatch."
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| with self.lock: | |
| self.kv_cache_size_bytes += transfer_size | |
| assert sender_slice_id < len(self._kv_tasks), ( | |
| f"Receiver got slice_id={sender_slice_id} from sender but only has " | |
| f"{len(self._kv_tasks)} receive task(s) for request {self.request_id}. " | |
| f"Sender/receiver slice count mismatch." | |
| assert receiver_slice_id < len(self._kv_tasks), ( | |
| f"Receiver got receiver_slice_id={receiver_slice_id} (sender_slice_id=" | |
| f"{sender_slice_id}) but only has {len(self._kv_tasks)} receive task(s) " | |
| f"for request {self.request_id}. Sender/receiver slice count mismatch." | |
| ) | |
| task = self._kv_tasks[sender_slice_id] | |
| task = self._kv_tasks[receiver_slice_id] | |
| with self.lock: | |
| self.kv_cache_size_bytes += transfer_size | |
| assert 0 <= receiver_slice_id < len(self._kv_tasks), ( | |
| f"Receiver got receiver_slice_id={receiver_slice_id} (sender_slice_id=" | |
| f"{sender_slice_id}) but only has {len(self._kv_tasks)} receive task(s) " | |
| f"for request {self.request_id}. Sender/receiver slice count mismatch." | |
| ) | |
| task = self._kv_tasks[receiver_slice_id] |
🤖 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 2014 -
2021, Update the assertion in the lock block before indexing self._kv_tasks to
require receiver_slice_id >= 0 as well as being less than len(self._kv_tasks).
Preserve the existing mismatch error context, then use the validated
receiver_slice_id to select the task.
| kv_cache_transceiver = create_kv_cache_transceiver( | ||
| mapping, dist, kv_cache_manager, attention_type, | ||
| cache_transceiver_config, mamba_cache_manager) | ||
| mapping, | ||
| dist, | ||
| kv_cache_manager, | ||
| attention_type, | ||
| cache_transceiver_config, | ||
| mamba_cache_manager, | ||
| enable_chunked_prefill=llm_args.enable_chunked_prefill) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Pass the effective chunked-prefill state.
create_py_executor() can set enable_chunked_context = False after validating the original LLM arguments. This call still passes llm_args.enable_chunked_prefill, so the factory accepts pipelined transfer although the scheduler has no chunked prefill. Pass ctx_chunk_config is not None, or pass the resolved enable_chunked_context through create_py_executor_instance.
🤖 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/pyexecutor/_util.py` around lines 3000 - 3007, Update the
call to create_kv_cache_transceiver in create_py_executor to pass the effective
chunked-prefill state (ctx_chunk_config is not None or the resolved
enable_chunked_context) instead of llm_args.enable_chunked_prefill, ensuring the
factory reflects the scheduler’s actual configuration.
| logger.warning( | ||
| "enable_pipelined_transfer is set; auto-selecting the Python " | ||
| "transceiver instead of the C++ transceiver to enable " | ||
| "pipelined KV cache transfer. " | ||
| "Set transceiver_runtime='CPP' to disable this auto-selection.") | ||
| cache_transceiver_config.transceiver_runtime = "PYTHON" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Correct the runtime guidance.
When enable_pipelined_transfer is enabled, transceiver_runtime="CPP" raises ValueError at Lines 143-146. It does not disable pipelined transfer. Tell users to set transceiver_runtime="PYTHON" explicitly, or to disable enable_pipelined_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 `@tensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.py` around lines 137 -
142, Update the warning in the enable_pipelined_transfer handling to state that
users must explicitly set transceiver_runtime="PYTHON" or disable
enable_pipelined_transfer; remove the incorrect guidance that
transceiver_runtime="CPP" disables pipelined transfer. Keep the automatic
cache_transceiver_config.transceiver_runtime assignment unchanged.
| @pytest.mark.skip_less_device(2) | ||
| @parametrize_with_ids("enable_block_reuse", [True]) | ||
| @parametrize_with_ids("disable_overlap_scheduler", [False]) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add @skip_pre_hopper to match the sibling NIXL Python tests.
TestGemma3_1BInstruct::test_auto_dtype and TestGemma3_1BInstruct::test_kv_cache_v2_nixl_python both carry @skip_pre_hopper. This new test uses the same NIXL Python transceiver path but has no architecture guard. On pre-Hopper hardware the test runs instead of skipping.
🩹 Proposed fix
`@pytest.mark.skip_less_device`(2)
+ `@skip_pre_hopper`
`@parametrize_with_ids`("enable_block_reuse", [True])
`@parametrize_with_ids`("disable_overlap_scheduler", [False])
def test_pipelined_kv_transfer_nixl_python_accuracy(📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @pytest.mark.skip_less_device(2) | |
| @parametrize_with_ids("enable_block_reuse", [True]) | |
| @parametrize_with_ids("disable_overlap_scheduler", [False]) | |
| `@pytest.mark.skip_less_device`(2) | |
| `@skip_pre_hopper` | |
| `@parametrize_with_ids`("enable_block_reuse", [True]) | |
| `@parametrize_with_ids`("disable_overlap_scheduler", [False]) |
🤖 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 `@tests/integration/defs/accuracy/test_disaggregated_serving.py` around lines
1593 - 1595, Add the skip_pre_hopper decorator to the new test’s decorator list
alongside skip_less_device and the existing parameterization decorators,
matching the guards on TestGemma3_1BInstruct::test_auto_dtype and
test_kv_cache_v2_nixl_python.
| # ------------- Disaggregated Serving: Pipelined KV Transfer (multi-GPU) --------------- | ||
| - accuracy/test_disaggregated_serving.py::TestLlama3_1_8BInstruct::test_pipelined_kv_transfer_nixl_python_accuracy[disable_overlap_scheduler=False-enable_block_reuse=False] | ||
| - accuracy/test_disaggregated_serving.py::TestLlama3_1_8BInstruct::test_pipelined_kv_transfer_nixl_python_accuracy[disable_overlap_scheduler=False-enable_block_reuse=True] | ||
| - accuracy/test_disaggregated_serving.py::TestLlama3_1_8BInstruct::test_pipelined_kv_transfer_nixl_python_accuracy[disable_overlap_scheduler=True-enable_block_reuse=False] | ||
| - accuracy/test_disaggregated_serving.py::TestLlama3_1_8BInstruct::test_pipelined_kv_transfer_nixl_python_accuracy[disable_overlap_scheduler=True-enable_block_reuse=True] | ||
| - accuracy/test_disaggregated_serving.py::TestGemma3_1BInstruct::test_pipelined_kv_transfer_nixl_python_accuracy[disable_overlap_scheduler=False-enable_block_reuse=True] |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm the surrounding YAML structure and tier for the new entries.
fd -t f 'l0_dgx_b200.yml' --exec sed -n '1,40p' {}
# Show the parametrize decorators so the generated ids can be derived.
rg -n -B8 'def test_pipelined_kv_transfer_nixl_python_accuracy' \
tests/integration/defs/accuracy/test_disaggregated_serving.py
# Confirm the ids are not also present in other test-db lists.
fd -t f -e yml . tests/integration/test_lists/test-db \
--exec rg -n 'test_pipelined_kv_transfer_nixl_python_accuracy' {}Repository: NVIDIA/TensorRT-LLM
Length of output: 4301
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
yaml = Path("tests/integration/test_lists/test-db/l0_dgx_b200.yml").read_text()
source = Path("tests/integration/defs/accuracy/test_disaggregated_serving.py").read_text()
# Extract the first relevant YAML block and its five entries.
block = yaml.split("- condition:", 1)[1]
block = block.split("- condition:", 1)[0]
entries = [
line.strip()[2:]
for line in block.splitlines()
if "test_pipelined_kv_transfer_nixl_python_accuracy[" in line
]
print("test-db entries:", len(entries))
for entry in entries:
print(entry)
# Extract the focused method bodies and their immediate decorator stacks.
for cls in ("TestLlama3_1_8BInstruct", "TestGemma3_1BInstruct"):
cls_match = re.search(
rf"class {cls}\b(?P<body>.*?)(?=\nclass |\Z)", source, re.S
)
if not cls_match:
print(f"{cls}: class not found")
continue
body = cls_match.group("body")
method_match = re.search(
r"(?P<decorators>(?: @[^\n]+\n)+)"
r" def test_pipelined_kv_transfer_nixl_python_accuracy\((?P<signature>.*?)\n \):"
r"(?P<method_body>.*?)(?=\n def |\nclass |\Z)",
body,
re.S,
)
if not method_match:
print(f"{cls}: method not found")
continue
decorators = [
line.strip()
for line in method_match.group("decorators").splitlines()
if "parametrize_with_ids" in line
]
print(f"{cls} parameter decorators:")
for decorator in decorators:
print(" ", decorator)
# Pytest applies stacked parametrizations in the observed ID order:
# the lower decorator's parameter is the first ID fragment.
params = []
for decorator in reversed(decorators):
m = re.search(r'"([^"]+)",\s*\[([^\]]+)\]', decorator)
if not m:
raise SystemExit(f"Cannot parse decorator: {decorator}")
values = [v.strip() for v in m.group(2).split(",")]
params.append((m.group(1), [v for v in values]))
generated = []
def visit(i, parts):
if i == len(params):
generated.append(
f"{cls}::test_pipelined_kv_transfer_nixl_python_accuracy["
+ "-".join(f"{name}={value}" for name, value in parts)
+ "]"
)
return
name, values = params[i]
for value in values:
visit(i + 1, parts + [(name, value)])
visit(0, [])
print("derived IDs:")
for item in generated:
print(" ", item)
# Confirm the five exact entries are in only one test-db file.
matches = []
needle = "test_pipelined_kv_transfer_nixl_python_accuracy"
for path in Path("tests/integration/test_lists/test-db").rglob("*.yml"):
text = path.read_text()
count = text.count(needle)
if count:
matches.append((str(path), count))
print("test-db files containing target:", matches)
PY
sed -n '720,775p' tests/integration/defs/accuracy/test_disaggregated_serving.py
sed -n '1565,1615p' tests/integration/defs/accuracy/test_disaggregated_serving.pyRepository: NVIDIA/TensorRT-LLM
Length of output: 5651
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- class declarations and helper ---'
rg -n '^(class |def parametrize_with_ids|parametrize_with_ids\s*=|from .*parametrize_with_ids)' \
tests/integration/defs/accuracy/test_disaggregated_serving.py \
tests/integration/defs -g '*.py' | head -80
printf '%s\n' '--- focused Llama method ---'
sed -n '735,790p' tests/integration/defs/accuracy/test_disaggregated_serving.py
printf '%s\n' '--- focused Gemma method ---'
sed -n '1588,1665p' tests/integration/defs/accuracy/test_disaggregated_serving.py
printf '%s\n' '--- possible CBTS artifacts ---'
find . -maxdepth 5 \( -name 'cbts_touchmap.sqlite' -o -iname '*cbts*coverage*' -o -iname '*coverage*report*' \) -printRepository: NVIDIA/TensorRT-LLM
Length of output: 14281
🏁 Script executed:
#!/bin/bash
set -eu
helper_file="$(rg -l 'def parametrize_with_ids' tests/integration -g '*.py' | head -1)"
printf 'helper_file=%s\n' "$helper_file"
rg -n -A35 -B5 'def parametrize_with_ids' "$helper_file"Repository: NVIDIA/TensorRT-LLM
Length of output: 1862
Reduce the pre-merge matrix for disaggregated accuracy tests.
l0_dgx_b200.yml adds four Llama and one Gemma node IDs to the stage: pre_merge tier. Each test launches one context server, one generation server, and a full GSM8K evaluation. Move lower-priority combinations to post-merge or retain only representative combinations.
Test coverage summary: no test functions changed; five test-list entries were added and none were removed. All five entries match collected node IDs. Coverage verdict: needs follow-up because no cbts_touchmap.sqlite or CBTS coverage report is available.
🤖 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 `@tests/integration/test_lists/test-db/l0_dgx_b200.yml` around lines 21 - 26,
Reduce the pre_merge entries under the “Disaggregated Serving: Pipelined KV
Transfer (multi-GPU)” section in l0_dgx_b200.yml by retaining only
representative Llama and Gemma combinations; move the lower-priority node IDs to
the post_merge tier instead of adding all five to pre_merge.
Source: Path instructions
…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>
085a8d6 to
661dee6
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (3)
tests/unittest/disaggregated/test_chunked_transfer.py (3)
661-666: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the stale
_chunk_size_blockssetup.
pipeline_transfer_enabledreads only_enable_pipelined_transfer.KvCacheTransceiverV2no longer defines_chunk_size_blocks, so line 663 configures an attribute the property never reads. Removing it keeps the test aligned with the current property.♻️ Proposed cleanup
transceiver = MagicMock() transceiver._enable_pipelined_transfer = False - transceiver._chunk_size_blocks = 64 result = KvCacheTransceiverV2.pipeline_transfer_enabled.fget(transceiver)🤖 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 `@tests/unittest/disaggregated/test_chunked_transfer.py` around lines 661 - 666, Remove the unnecessary transceiver._chunk_size_blocks assignment from the test for KvCacheTransceiverV2.pipeline_transfer_enabled, leaving the _enable_pipelined_transfer setup and assertion unchanged.
833-853: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSet
pipeline_transfer_enabledexplicitly and add a monolithic-branch test.
transceiveris aMagicMock, sotransceiver.pipeline_transfer_enabledis truthy no matter what_enable_pipelined_transferis set to. These tests therefore take the pipelined branch through mock truthiness, not through the flag. No test covers theelsebranch at line 760 oftensorrt_llm/_torch/disaggregation/transceiver.py, which is the default non-pipelined path that calls_create_kv_slice.Set the property on the mock directly, and add a test for the monolithic path.
💚 Proposed test additions
transceiver = MagicMock() - transceiver._enable_pipelined_transfer = True + transceiver.pipeline_transfer_enabled = True transceiver.kv_transfer_timeout_ms = None transceiver._get_or_create_send_session.return_value = session transceiver._build_prefill_chunk.return_value = last_sliceAdd a companion test for the non-pipelined path:
def test_monolithic_transfer_sends_full_slice(): """With pipelining off, respond_and_send_async sends one full-prompt slice.""" from tensorrt_llm._torch.disaggregation.transceiver import KvCacheTransceiverV2 session = MagicMock() full_slice = KVSlice( is_last_slice=True, block_ids_per_layer_groups=[np.array([0, 1], dtype=np.int64)], ) transceiver = MagicMock() transceiver.pipeline_transfer_enabled = False transceiver._get_or_create_send_session.return_value = session transceiver._create_kv_slice.return_value = full_slice request = SimpleNamespace( py_disaggregated_params=DisaggregatedParams(disagg_request_id=42), request_id=42, prompt_len=8, py_beam_width=1, py_kv_transfer_start_time=None, set_kv_cache_transfer_start=lambda _ts: None, ) KvCacheTransceiverV2.respond_and_send_async(transceiver, request) transceiver._create_kv_slice.assert_called_once_with(request) transceiver._build_prefill_chunk.assert_not_called() session.send.assert_called_once_with(full_slice) transceiver._finalize_send.assert_called_once_with(request, session)🤖 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 `@tests/unittest/disaggregated/test_chunked_transfer.py` around lines 833 - 853, Set pipeline_transfer_enabled directly on the MagicMock in the existing pipelined test so branch selection uses the intended flag rather than mock truthiness. Add a companion test for KvCacheTransceiverV2.respond_and_send_async with pipeline_transfer_enabled=False, asserting _create_kv_slice(request) is called, _build_prefill_chunk is not called, and the resulting full slice is sent and finalized.
15-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest coverage: add two missing cases.
- The new file adds all listed
test_*functions; no tests were modified or removed.- Unit tests require no per-test list entry. The B200 test list contains five pipelined accuracy entries.
- Coverage is insufficient. Add a non-block-aligned
chunk_end_poscase for_build_prefill_chunk, and test therespond_and_send_asyncnon-pipelined branch (_create_kv_slice).🤖 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 `@tests/unittest/disaggregated/test_chunked_transfer.py` around lines 15 - 19, Extend the tests in the chunked transfer test module with two cases: cover a non-block-aligned chunk_end_pos for _build_prefill_chunk, and cover the non-pipelined respond_and_send_async path, including its _create_kv_slice behavior. Keep the existing pipelined accuracy entries and test structure unchanged.Source: Path instructions
🤖 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.
Nitpick comments:
In `@tests/unittest/disaggregated/test_chunked_transfer.py`:
- Around line 661-666: Remove the unnecessary transceiver._chunk_size_blocks
assignment from the test for KvCacheTransceiverV2.pipeline_transfer_enabled,
leaving the _enable_pipelined_transfer setup and assertion unchanged.
- Around line 833-853: Set pipeline_transfer_enabled directly on the MagicMock
in the existing pipelined test so branch selection uses the intended flag rather
than mock truthiness. Add a companion test for
KvCacheTransceiverV2.respond_and_send_async with
pipeline_transfer_enabled=False, asserting _create_kv_slice(request) is called,
_build_prefill_chunk is not called, and the resulting full slice is sent and
finalized.
- Around line 15-19: Extend the tests in the chunked transfer test module with
two cases: cover a non-block-aligned chunk_end_pos for _build_prefill_chunk, and
cover the non-pipelined respond_and_send_async path, including its
_create_kv_slice behavior. Keep the existing pipelined accuracy entries and test
structure unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: e64bf3a5-860b-4669-bf05-3aeefe7622e2
📒 Files selected for processing (4)
tensorrt_llm/_torch/disaggregation/transceiver.pytensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.pytests/unittest/_torch/executor/test_disagg_inflight_cancel_gate.pytests/unittest/disaggregated/test_chunked_transfer.py
🚧 Files skipped from review as they are similar to previous changes (1)
- tensorrt_llm/_torch/pyexecutor/kv_cache_transceiver.py
Documentation (WIP) https://docs.google.com/document/d/1Z9ARCc48QNCbKfoTEhZT1W4W440x6QsbKfVh3ksKIW0/edit?tab=t.0
Status: Benchmarking performance impact
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_kv_cache_transceiver(ValueError)beam_width == 1PyExecutor._validate_request(ValueError), asserted again in_build_prefill_chunkKvCacheTransceiverV2resolve_cache_transceiver_configkv_cache_bounce_size_mb == 0resolve_cache_transceiver_config(ValueError)pipeline_parallel_size == 1on the sendercreate_kv_cache_transceiver(ValueError); the generation server is unaffected and may still use PPcreate_kv_cache_transceiverrejectsMambaHybridCacheManagerand any explicit Mamba cache manager (ValueError)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 applies to both
MixedMambaHybridCacheManagerandCppMambaHybridCacheManager. 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.total_blocks(new) is the full logical block span[0, total_blocks)that chunk offsets and the resident-suffix projection are expressed in. Without it, a receiver-side projection has no way to tell where a resident suffix begins.token_rangefor a pipelined chunk is block-aligned and encodes the chunk's global position. For a monolithic transfer it stays[0, prompt_len + num_extra_kv_tokens)and is not required to be block-aligned.SessionArgsBase.prompt_len(and theTxSession/RxSession/KVSendTaskconstructors) changed fromOptional[int]to required. SWA needs the request'sprompt_lento compute the stale-block boundary:Previously
token_range.endwas an acceptable stand-in forprompt_len. For a non-final chunk it is not —token_range.endis the chunk boundary, and 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, builds a chunk with_build_prefill_chunkwhen pipelining is on, and only calls_finalize_sendand setsDISAGG_CONTEXT_TRANS_IN_PROGRESSwhenslice.is_last_sliceis true.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.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 whatKVSlice.total_blockscarries to the sender worker. The chunk bounds are clamped to it, and the resultingtoken_rangeis block-aligned by construction. 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.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 totoken_range.start == 0withis_last_slice, sois_chunkedevaluates false and the write takes the monolithic path — byte-for-byte a non-pipelined transfer.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:derive_chunk_block_coords(token_range, tpb)recovers(chunk_offset, chunk_block_count)from the block-alignedtoken_range, raisingValueErrorif it is not block-aligned.is_chunkedpredicate —not is_last_slice, ortoken_range.start > 0— decides whether to derive chunk coordinates at all. A monolithic transfer'stoken_rangeends atprompt_len + num_extra_kv_tokensand need not be block-aligned, so it must not go throughderive_chunk_block_coords.suffix_end_blocksischunk_offset + chunk_block_countwhen chunked andtotal_blocksotherwise; 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:
sender_slice_idandreceiver_slice_idThe
KV_AGENT_RESULTframe used to carry a single slice id. The receiver used it purely as an index into its own task list, so the field was always semantically the receiver's id — but it was namedsender_slice_idon the receive side, and once chunking existed the sender hardcoded0into it while its real chunk id sat unused inwrite_meta.slice_id. The consequences were a per-chunk RDMA failure that always loggedslice=0, and two send paths that disagreed about what the field meant (_send_failed_result_to_receiveralready sentinfo.slice_id, while_send_kv_result_to_receiversent a literal0).The frame now carries both ids explicitly:
sequenceDiagram participant TxSession participant SenderWorker participant RxSession TxSession->>SenderWorker: KVSendTask.slice_id = chunk index 0..N-1 Note over SenderWorker: WriteMeta.sender_slice_id = task.slice_id<br/>WriteMeta.receiver_slice_id = req_info.slice_id SenderWorker->>RxSession: KV_AGENT_RESULT with both ids, is_last, status Note over RxSession: index _kv_tasks[receiver_slice_id]<br/>log sender_slice_id only_KV_RESULT_PREFIXwidened fromstruct.Struct("<qqq?B")tostruct.Struct("<qqqq?B"); the field order isinstance_rank, unique_rid, sender_slice_id, receiver_slice_id, is_last, status. This is a ctx/gen wire format with no version negotiation — the receiver unpacks whatever arrives against its compiled-in struct, so both servers must run matching builds.NO_SLICE_ID = -1means "no sender slice for this result", used by_send_failed_result_to_receiverwhen a session fails before anyKVSendTaskexists.WriteMeta.sender_slice_id = task.slice_ididentifies the sender's chunk and is carried for logging and cross-side correlation only.WriteMeta.receiver_slice_id = req_info.slice_id if req_info.slice_id is not None else 0is the peer's own task index, echoed back fromRecvReqInfo, and is what resolves the task.RxSession.process_kv_agent_result(peer_rank, receiver_slice_id, sender_slice_id, is_last_slice, status, ...)indexes_kv_tasks[receiver_slice_id]and includessender_slice_idin the assertion message, the bounce-scatter-failure path, the perf warning, the completion debug line, and the FAILED detail — so a chunk-level RDMA failure is attributable to a specific chunk.This is behavior-neutral for the monolithic receiver:
receiver_slice_idis0in every current deployment. It is naming plus an explicit protocol field, laying the groundwork for a future multi-task receiver.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)andhas_any_inflight_transfer()are non-abstract and default toFalse, which leavesBindKvCacheTransceiveruntouched — the C++ transceiver has no pipelining, so state and transfer activity coincide there.KvCacheTransceiverV2implements both 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_idsbefore the loop and gates only the intermediate-chunk branch on it. A session whose_terminal_statusis alreadyCANCELLEDwould otherwise be fed another chunk and produce a spuriousFAILEDresult to the receiver. The last-chunk branch is untouched so nothing can be stranded in_requests_in_transfer.PyExecutor._has_any_inflight_kv_transfer()ORskv_cache_transceiver.has_any_inflight_transfer()intoasync_transfer_manager.has_any_inflight_requests(), and replaces the latter at the four loop-level gates that would otherwise suppress timeout checking during the pipelined phase (the three_check_kv_transfer_timeoutcall sites and the KV-pressureRuntimeError)._check_kv_transfer_timeoutgains a sweep over active context-only requests that are absent fromrequests_in_transfer, have a non-Nonepy_kv_transfer_start_time, and have an in-flight transfer._check_disagg_ctx_cache_transfer_statusgains the matching recovery sweep: for a request flaggedpy_kv_transfer_timed_outbut not yet known to the transfer manager, callcancel_requestand setDISAGG_TRANS_ERRORonly when it returnsTrue(session closed, nothing mid-write). That reuses the established lever —_check_cache_transfer_errorspicks it up for non-ADP and_handle_disagg_cache_errors_syncedvotes on it under ADP — so no new divergence path is introduced.Dev Engineer Review
generation_first, beam width 1, Python NIXL, zero bounce buffer, sender PP size 1, and unsupported Mamba or hybrid cache configurations.send_prefill_chunk,_maybe_send_prefill_chunk,create_kv_cache_transceiver, andrequest_and_receive_async.QA Engineer Review
test_transfer_worker_chunked,test_transfer_worker_pipelined, andtest_transfer_worker_pipelined_ctx_prefix_reuse.