[TRTLLM-13308][feat] Make the KVCacheManagerV2 KV pool rebalance safe under TP, CP, PP and attention DP - #17391
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:
WalkthroughThe executor now throttles KV pool rebalance checks and synchronizes tensor-parallel and context-parallel decisions. Unit, accuracy, and real-MPI tests validate interval behavior, rank agreement, attention-DP independence, and pool-ratio changes. ChangesKV pool rebalance coordination
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant PyExecutor
participant KVCacheManager
participant MPIDist
PyExecutor->>KVCacheManager: read local adjustment state
PyExecutor->>MPIDist: broadcast rank-0 decision for tensor or context parallelism
MPIDist-->>PyExecutor: return agreed decision
PyExecutor->>KVCacheManager: rebalance when the decision is true
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@tests/unittest/_torch/multi_gpu/test_kv_pool_rebalance_tp.py`:
- Around line 77-81: Remove the broad try/except block from run_single_rank and
invoke single_rank_forward_func directly, allowing MPIPoolExecutor to propagate
the original failure unchanged; only add a handler if a documented specific
exception requires handling.
🪄 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: a9e5c753-c65b-457e-ae26-c5efdc6a2333
📒 Files selected for processing (4)
tensorrt_llm/_torch/pyexecutor/py_executor.pytests/integration/defs/accuracy/test_kv_pool_rebalance_accuracy.pytests/unittest/_torch/executor/test_kv_pool_rebalance.pytests/unittest/_torch/multi_gpu/test_kv_pool_rebalance_tp.py
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@tests/unittest/_torch/multi_gpu/test_kv_pool_rebalance_tp.py`:
- Around line 119-122: Update
tests/unittest/_torch/multi_gpu/test_kv_pool_rebalance_tp.py at lines 119-122
for _cp_dist: add Google-style Args and Returns sections while preserving its
existing behavior; at lines 152-159 annotate every parameter and the return type
as None, then add a Google-style docstring; at lines 284-285 annotate world_size
and case plus -> None, then add a Google-style docstring.
- Around line 279-296: Add a real-MPI CP-then-TP agreement case alongside
test_cp_ranks_agree_on_rebalance_trigger using world_size=4, cp_size=2, and
tp_size=2, with divergent flags such as [True, False, False, False]. Exercise
the production collective chain through cp_broadcast() followed by
tp_broadcast(), and assert every rank receives rank 0’s decision; retain the
existing pure-CP coverage.
🪄 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: 8c9b6071-a5f0-4f25-b6b0-9e9d6d2a759b
📒 Files selected for processing (3)
tensorrt_llm/_torch/pyexecutor/py_executor.pytests/unittest/_torch/executor/test_kv_pool_rebalance.pytests/unittest/_torch/multi_gpu/test_kv_pool_rebalance_tp.py
🚧 Files skipped from review as they are similar to previous changes (2)
- tests/unittest/_torch/executor/test_kv_pool_rebalance.py
- tensorrt_llm/_torch/pyexecutor/py_executor.py
brnguyen2
left a comment
There was a problem hiding this comment.
Thorough work — the PR description alone settles most questions a reviewer would have, and the anti-vacuity assertions in the accuracy test plus the real-MPI lockstep tests are exactly the right coverage for this kind of collective.
One correctness concern (inline at [py_executor.py:4461](https://github.com/NVIDIA/TensorRT-LLM/pull/17391/files#diff-f0b4c3c02708916fd189f863c48b982a55f34ae94996089b23aa0a6f0571fe19R4461)): the attention-DP early-return skips the CP broadcast as well as the TP one, but the scheduler propagation cited as precedent gates only the TP hop on ADP — cp_broadcast at [py_executor.py:2475](https://github.com/NVIDIA/TensorRT-LLM/pull/17391/files#diff-f0b4c3c02708916fd189f863c48b982a55f34ae94996089b23aa0a6f0571fe19R2475) runs unconditionally. Under ADP + CP, CP ranks share a request stream, so they need the trigger agreement even though the TP-dimension exclusion is correct. Details and a suggested fix inline.
Also verified while reviewing (no action needed): adjust() re-checks only the deterministic per-level ratio-skew test, not the cooldown or the 2000-sample gate (kvCacheManager.cpp:862-874), so a follower whose local cooldown hadn't expired but is forced True by rank 0 performs the same _adjustLevel work rather than no-opping — and re-stamps mLastAdjustmentTime, which conveniently re-syncs the per-rank clocks after the first agreed adjustment. That property is what makes trigger-only agreement sufficient; might be worth a sentence in the _agreed_need_adjustment docstring, since the mechanism silently depends on it.
On the [None] tag: this is a real bug fix (cross-rank divergence reachable in any TP job with the feature enabled). Worth filing a tracking ticket and referencing it in the title, per the usual convention for fixes.
|
|
||
| Broadcasting over the CP group and then the TP group propagates global | ||
| rank 0's decision to everyone: after the CP step each rank holds | ||
| ``V(its tp_rank, cp_rank 0)``, and the TP step then replaces that with |
There was a problem hiding this comment.
This early return skips the CP broadcast too, but the scheduler propagation cited as the precedent gates only the TP hop on attention DP — cp_broadcast at [py_executor.py:2475](https://github.com/NVIDIA/TensorRT-LLM/pull/17391/files#diff-f0b4c3c02708916fd189f863c48b982a55f34ae94996089b23aa0a6f0571fe19R2475) runs unconditionally, ADP or not. That's because ADP gives each TP rank its own request stream, but CP ranks within a DP group still split the same requests and must admit them together. Under ADP + CP (nothing in Mapping or llm_args rejects the combination), CP ranks would decide the rebalance trigger independently here — the same divergence class this PR fixes.
Suggested structure, mirroring [py_executor.py:2471](https://github.com/NVIDIA/TensorRT-LLM/pull/17391/files#diff-f0b4c3c02708916fd189f863c48b982a55f34ae94996089b23aa0a6f0571fe19R2471)-2478:
need = self.kv_cache_manager.impl.need_adjustment
if self.dist.cp_size > 1:
need = self.dist.cp_broadcast(need, root=0)
if self.dist.tp_size > 1 and not self.enable_attention_dp:
need = self.dist.tp_broadcast(need, root=0)
return needIf ADP + CP + rebalance is instead considered unreachable today, a comment saying so (and why) would do — but test_attention_dp_skips_cp_broadcast_too and test_attention_dp_ranks_decide_independently currently bake the skip in as intended behavior, so either the code or the tests should change.
There was a problem hiding this comment.
Good catch — you're right, and I had the precedent backwards. Fixed in ba6fff1.
I cited the scheduler's tp_broadcast as justification for excluding ADP outright and missed that the cp_broadcast immediately below it (py_executor.py:2474) runs unconditionally. That asymmetry is the whole answer, and the reasoning you give for it is the same one this PR already makes for pure CP — I just failed to carry it into the ADP branch.
Confirmed reachable: nothing in Mapping or LlmArgs rejects enable_attention_dp with cp_size > 1. Mapping.__init__'s only ADP-related assert is enable_lm_head_tp_in_adp requires enable_attention_dp (mapping.py:190), and dp_size = tp_size if enable_attention_dp else 1 (:296) makes CP orthogonal to the DP dimension rather than folded into it.
Took your structure verbatim:
need = self.kv_cache_manager.impl.need_adjustment
if self.dist.cp_size > 1:
need = self.dist.cp_broadcast(need, root=0)
if self.dist.tp_size > 1 and not self.enable_attention_dp:
need = self.dist.tp_broadcast(need, root=0)
return needThe CP-then-TP order differs from the scheduler's TP-then-CP, but the two agree in both regimes: without ADP, TP-then-CP gives rank(t,c) <- V(0,c) <- V(0,0) and CP-then-TP gives rank(t,c) <- V(t,0) <- V(0,0); with the TP hop suppressed both reduce to rank(t,c) <- V(t,0), i.e. each replica decides on its own cp_rank-0 reading.
On tests — you were right that they baked the skip in, so I inverted rather than extended:
test_attention_dp_skips_cp_broadcast_too->test_attention_dp_still_broadcasts_over_cp(asserts the CP result overrides the local reading and thattp_broadcaststays uncalled).- Added
test_attention_dp_without_cp_touches_no_collectiveso the genuine no-collective case keeps coverage. - Added a real-MPI case,
test_attention_dp_agrees_over_cp_but_not_tp(world_size=4, cp_size=2, tp_size=2). Flags are[True, False, False, True]so it fails in both directions: ranks 1 and 3 are overridden by their CP root (proving the CP hop ran) while the two replicas end on different answers (proving the TP hop did not).
Mutation-verified — restoring the early return gives:
AssertionError: ADP+CP decisions were [True, False, False, True],
expected [True, True, False, False]: CP ranks must follow their replica's
root while replicas stay independent
which is exactly the raw local flags, i.e. no broadcast at all. The pure-TP, pure-CP and CP-x-TP cases all stay green under that mutation, which is your point that nothing covered this topology.
One note for precision: the propagation you cite lives in _pp_schedule_and_propagate, the PP scheduling path, and rebalance is gated off for pp_size > 1. So it's a semantic precedent rather than an operative one — in the non-PP loops _schedule() runs per rank with no broadcast at all, which is this PR's premise. Doesn't change the conclusion; the ADP+CP semantics stand on their own.
Suites after the fix: 30 mock tests, 20 real-MPI tests, pre-commit clean.
BowenFu
left a comment
There was a problem hiding this comment.
Verified existing/default behavior remains unchanged behind the default-off rebalance gate; when enabled, the loop-wide cadence and CP/TP agreement are covered by focused unit and MPI tests, including ADP+CP.
The unresolved py_executor.py thread is non-blocking: ba6fff1 fixes the cadence/ADP issue it raised, and the author response documents the correction.
|
/bot run --disable-fail-fast |
|
PR_Github #65044 [ run ] triggered by Bot. Commit: |
|
PR_Github #65965 [ run ] triggered by Bot. Commit: |
Under attention DP the rebalance decision was rank-local: _agreed_need_adjustment
skipped the TP hop entirely, on the argument that ADP ranks own independent
request streams and independent KV caches and so legitimately want different pool
ratios at different times.
That argument holds for the ratios but not for the timing, because the path the
decision gates is itself collective under ADP.
_consume_previous_batch_for_rebalance calls _flush_pending_transfer_responses,
which -- as its own docstring requires -- enters _enqueue_responses on every DP
rank even with an empty response list, and that runs a tp_gather. A rank
rebalancing alone therefore joined a collective its peers were not in.
Live on Qwen3-Next-80B-A3B (tp4 = ep4, attention DP, 4xH100), with only rank 0's
need_adjustment true, the gather paired against the tp_allgather of batch sizes
in _can_queue and the executor loop died:
TypeError: cannot unpack non-iterable int object
HangDetector: propagating hard-kill to all ranks via MPI_Abort
The TP hop now runs under ADP as an OR-reduction instead of being skipped, so the
ranks agree on *when* to rebalance while each still computes its own ratios.
any() rather than rank 0's broadcast keeps the anti-starvation property the
suppression was reaching for: a rank that needs a rebalance gets one without
waiting for rank 0 to want the same thing.
Same configuration after the change: exit 0, with ranks 1-3 rebalancing off rank
0's need without having been injected.
Measured cost on that model (2 pool groups, 17 active requests per rank):
- full hook, rank that needed it 21.1 ms
- full hook, rank pulled in by a peer 8.1 ms, of which ~7.3 ms is the
pre-existing torch.cuda.current_stream().synchronize() and 0.03 ms is the
no-op adjust()
- a check that decides not to rebalance costs 0.13-1.46 ms, against
0.09-1.68 ms for the tp_broadcast already on the non-ADP path, so the added
collective is not measurably more expensive than the one it joins
- adjust() itself is bandwidth-bound at ~290 GB/s aggregate per node, so
synchronizing the ranks does not reduce total stall -- it concentrates it
into one hiccup instead of N. Correctness, not performance, is the reason
for this change.
Two behavioural consequences reviewers should weigh. The check now performs a
collective on the ADP path, so _can_pause_for_rebalance has to stay rank-uniform
under ADP as well as under plain TP -- the same requirement the non-ADP broadcast
already relies on, now extended. And adjust() stamps _last_adjustment_time
unconditionally, so a rank pulled into a peer's rebalance has its own 120s
cooldown reset by it: the group's cadence self-aligns, at the price of deferring
a need that arises immediately afterwards.
The unsafe call site predates this branch, but main's rebalance is documented as
single-GPU-only and ADP is reachable only in multi-GPU configurations, which is
what this PR exists to support.
Tests updated to the new semantics, including the two that asserted the old one
by name: attention_dp_ranks_decide_independently and
attention_dp_without_cp_touches_no_collective -- the latter asserted precisely
the property that made the crash possible.
Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
PR_Github #65965 [ run ] completed with state |
tests/unittest/_torch/multi_gpu is collected by exactly one test-db entry, the 2-GPU H100 pre-merge stage. Every 4-rank case of the KV-pool rebalance agreement test therefore skips in CI with "need 4 GPUs, have 2" -- 17 of the 30 cases in the last full run (pipeline 53654), including all of the composition coverage added by the recent work: tp4, pp4, CP x TP, TP x PP, and the attention-DP paths. The newest logic on this branch is exactly what CI could not reach. Name the file in the 4-GPU pytorch/others block so those cases execute. The 2-GPU stage keeps running the file as before via the directory entry; the 2-rank cases are cheap and the overlap is not worth a fragile -k filter. Verified on 4x GB300: all 31 cases pass in 5m22s. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot run --disable-fail-fast |
|
PR_Github #66106 [ run ] triggered by Bot. Commit: |
|
@lowsfer could you review this? thanks! |
…text own the statistic
Thor's hypothesis, confirmed with per-close sampling. The probe now records one
row per _KVCache.close() -- the only place _num_sampled_kv_caches increments --
installed before warmup, since warmup closes hundreds of caches.
Rank 1, 4,934 closes:
76 caches (1.5%) have avg_capacity >= 500k
their share of the sum-of-squares: 100.00%
RMS with them 130,139 (manager reports 131,703)
RMS without 313
Every one of the 76 is identical: capacity 1,048,576 == max_position_embeddings,
history = capacity - 2, n_cap_updates = 2 where a real 124-token generation does
~124. Dummy/warmup caches are allocated at the model's entire declared context,
not at max_num_tokens. avg_capacity is an RMS, so 1.5% of the population at 1M
swamps 4,858 real caches averaging 313 tokens, and the tuner sizes pools for
million-token sequences.
Explains two things that had looked mysterious:
* the capacity/history = 1.5000 constant is 699,049.3 / 1,048,575.5 = 2/3, an
artifact of 2 capacity updates against 3 history updates on those caches --
not a headroom policy
* --no-cuda-graph-padding raised avg_capacity 1.6x because it removed ~750
*small* caches, shrinking the denominator: 1,048,576 * sqrt(76/4934) = 130,139
Not a harness artifact: warmup and dummy requests are TRT-LLM's own. Reads as a
defect in the V2 auto-tuner that predates this PR -- _KVCache.close() samples
every cache with capacity > 0 and never excludes dummies, though the dummy path
sets req.is_dummy_request = True. What NVIDIA#17391 changes is that the rebalance now
fires across ranks, turning a latent mis-sizing into a 171 GB move. Section 9
says to lead with this rather than let a reviewer find it.
Corrects the previous framing. 5.5 had concluded the disagreement was
"structural, independent of traffic". Independent of traffic is right, but only
because real traffic contributes ~0% of the statistic; the target is set by
max_position_embeddings and the dummy-sampling pattern.
Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
Warmup and CUDA-graph padding requests reserve capacity at the model's full declared context rather than at a realistic sequence length, and the tuner averages the *square* of capacity. A handful of them therefore dominates the statistic outright and the pools get sized for sequences that never arrive. Measured on DeepSeek-V4-Flash (tp4=ep4, attention DP, 4x GB300), per rank: 76 of 4,934 sampled closes -- 1.5% -- supplied 100.00% of the sum of squares, every one at capacity 1,048,576 == max_position_embeddings. RMS with them 130,139; without them 313. avg_capacity read 131,703 against real sequences averaging ~530 tokens, and the resulting target ratio was computed for million-token sequences. Those caches are already marked stats-excluded at creation -- the dummy path sets is_dummy and _create_kv_cache calls mark_stats_excluded -- so close() only has to honour the flag that is already there. Deliberately not reusing _should_record_stats(), which also ANDs in the user-facing _stats_enabled toggle; disabling stats reporting must not blind the auto-tuner to real traffic. After the fix, on the same model and workload, avg_capacity reads 531-534 against ~530-token sequences and the tuner triggers organically. Behaviour change worth noting for reviewers: dummies were ~90% of the sample count, so the 2000-sample maturity gate used to open on a few hundred real sequences. It now needs 2000 real completed sequences per rank -- 1,024 prompts reached 257 samples and never triggered, 12,288 reached ~3,060 and triggered once. Low-traffic deployments will engage the feature later than they do today. Test asserts on need_adjustment rather than the counters, since neither has a getter on the C++ backend, and carries a control arm that closes an identically shaped cache without the exclusion -- without it the check would pass for the wrong reason. Mutation-tested: reverting the guard fails the test. Python backend: 130 tests OK (skipped=13). The C++ half of this change is NOT yet compiled or exercised; it needs a build before the default backend is fixed. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
…atch the code The C++ half of f95c99c is built and the default backend is fixed. Before the build, node 4 reproduced 22 §4's prediction exactly: TestPoolRebalance passes on the Python backend and fails on the C++ one with "a stats-excluded cache moved the target ratio". Those bindings come from the container image, i.e. from main -- so that failure is direct evidence main carries the bug today, arrived at independently of the mutation test. After the build (~75 min, cold ccache, 2,434 objects): TestPoolRebalance cpp 3 OK (was 1 FAIL) TestPoolRebalance python 3 OK full kv_cache_manager_v2_tests cpp 130 OK (skipped=13), 260.7 s full kv_cache_manager_v2_tests python 130 OK (skipped=13), 307.6 s The two backends agree at 130/13, so the close() guard changes nothing else in the manager. Second half of the session: PR NVIDIA#17391's description still said attention DP was *excluded* from the agreement -- the claim 70dd51f reversed -- and predated the PP extension, the 4-GPU stage entry and the tuner fix. A reviewer was being told the opposite of what the head commit does, in the subtlest area of the change. Title and body replaced; the tuner fix now leads, with the ADP retraction stated rather than quietly deleted, and TRTLLM-13308 in both the title prefix and the body. Commit messages deliberately left at [None] -- see §4, Thor's call. Traps recorded for the next node: gh pr edit aborts on an unrelated Projects-classic GraphQL error while changing nothing (use gh api -X PATCH, and read the PR back); CodeRabbit's auto-generated block re-states the old body and must be dropped, not kept; the build's [98%] plateau lasts ~35 of 75 minutes and is not a hang; and a foreground sleep is blocked in this harness, so poll loops built on it return instantly. The 31-case multi-GPU suite and the 57 executor unit tests were still running at commit time; tests-node4.log has their outcome. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
…indings 31/31 in test_kv_pool_rebalance_tp.py across 4 ranks in 314.8 s -- matching node 3's 5m22s to within noise -- and 57/57 in test_kv_pool_rebalance.py. These were the last way a bad build could have shown up, since they drive _agreed_need_adjustment against a real MPIDist rather than the manager. Every claim in PR NVIDIA#17391's Results table is now re-verified on this node except test_kv_pool_rebalance_accuracy.py, which needs gemma-3-1b-it weights that do not exist here, and the CI rows. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
The work log moved to https://gitlab-master.nvidia.com/tjohnsen/claude under RebalancingV2/, with history preserved via git subtree. This branch is kept as a historical copy. Without this commit a new node that found this branch first -- which is exactly what RESTART_INSTRUCTIONS.md told it to do -- would restore from here, work, and push back, forking the log silently. The same redirect box is on the GitLab side, so whichever copy a node reaches first sends it to the right one. This is the last intended commit on this branch. Only the notes moved; the code branch thor/kvcmv2-tp-rebalance-agreement and PR NVIDIA#17391 stay on GitHub. Signed-off-by: Thor Johnsen <41591019+thorjohnsen@users.noreply.github.com>
|
/bot kill |
|
/bot run --disable-fail-fast |
|
PR_Github #66194 [ kill ] triggered by Bot. Commit: |
|
PR_Github #66196 [ run ] triggered by Bot. Commit: |
|
PR_Github #66194 [ kill ] completed with state |
|
PR_Github #66106 [ run ] completed with state |
|
/bot run --disable-fail-fast |
|
PR_Github #66305 [ run ] triggered by Bot. Commit: |
|
PR_Github #66196 [ run ] completed with state |
|
PR_Github #66305 [ run ] completed with state
|
|
/bot run --disable-fail-fast |
|
PR_Github #66497 [ run ] triggered by Bot. Commit: |
|
PR_Github #66497 [ run ] completed with state
|
Description
Tracked by TRTLLM-13308.
The KV pool rebalance hook (
enable_kv_pool_rebalance) was documented as "MVP scope:single-GPU aggregated", but nothing enforced that:
_can_pause_for_rebalancenever checkedtp_size, and the only other constraint on the feature is Mamba + block reuse(
_util.py:236). So the hook was reachable — and unsafe — on every multi-GPU configuration.This PR makes it safe, and then extends it. In one table:
_can_pause_for_rebalancePlus one defect fix in the KVCacheManagerV2 auto-tuner that this PR does not cause but does
make expensive — please read that section first, it is the most surprising thing here.
Read this first: the auto-tuner is sized by warmup dummies, not by traffic
_KVCache.close()feeds every closed cache into the tuner's capacity statistics. Warmup andCUDA-graph padding requests reserve capacity at the model's full declared context rather
than at a realistic sequence length, and
_avg_sqr_capacityaverages the square of capacity.A handful of dummies therefore owns the statistic outright.
Measured on DeepSeek-V4-Flash-NVFP4 (tp4 = ep4, attention DP, 4× GB300), per rank:
capacity == max_position_embeddings == 1,048,576The tuner was sizing pools for million-token sequences on a workload whose sequences average
~530, and the resulting target is a pool inversion: applying it moves 171 GB per rank.
Those caches are already marked stats-excluded at creation — the dummy path sets
is_dummyand_create_kv_cachecallsmark_stats_excluded—close()simply neverconsulted the flag. The fix is one condition per backend:
Deliberately not reusing
_should_record_stats(): that also ANDs in the user-facing_stats_enabledtoggle, and turning off stats reporting must not blind the auto-tuner toreal traffic.
Behaviour change reviewers should weigh. Dummies were ~90% of the sample count, not just
of the RMS, so the 2000-sample maturity gate used to open on a few hundred real sequences. It
now needs 2000 real completed sequences per rank. Measured: 1,024 prompts → 257 samples, never
triggered; 12,288 prompts → ~3,060 samples, triggered once. Low-traffic deployments will engage
the feature later than they do today.
Scope note. This bug predates the PR — the tuner and its sampling both ship on
maintoday. It is included here because this PR is what makes the mis-sizing fire, on every rank,
as a 171 GB move; shipping the rebalance without it would turn a latent mis-sizing into a
visible one. Happy to split it out if the KV-cache-manager owners prefer.
Why the trigger has to be agreed at all
TP ranks are kept identical — same layers, same KV geometry, same request stream — so the hook
should fire on the same iteration on every rank by construction. Auditing every input to
need_adjustmentfound that holds for all of them except one.Deterministic across ranks:
mNumSampledKvCacheskvCacheManager.h:290,kvCache.cpp:563++; no RNG in the V2 treemAvgReusedLength,mAvgSqrCapacity,mAvgSqrHistoryLengthkvCache.cpp:105,561-562kvCacheManager.cpp:853,784kvCacheManager.cpp:781-796,838Not deterministic — the 120 s cooldown:
mLastAdjustmentTimeis stamped per rank, in the constructor (:126) and at the end ofadjust()(:873). Nothing ties those readings together, so two ranks can straddle theboundary on different iterations and rebalance one iteration apart. In that window they hold
different pool geometry, and
_prepare_and_schedule_batch(py_executor.py:3618) runs_schedule()on every rank independently with no broadcast — so they can admit differentrequests and issue mismatched collectives. The window is small, but it gets a fresh roll every
120 s.
Fix. TP rank 0 decides and broadcasts (
_agreed_need_adjustment). Only the trigger isagreed — the ratios are still computed independently on every rank and never exchanged,
because they are pure functions of statistics that are already identical. Two properties make
that safe:
needAdjustment()isconst(kvCacheManager.h:238), so only rank 0's valuemattering is harmless; and
tryUpdateTargetRatios()runs from the KvCache close path(
kvCache.cpp:564), not from this read, so every rank keeps maintaining its own ratios.To keep the broadcast off the hot path the check is throttled to once every
KV_POOL_REBALANCE_CHECK_INTERVAL(10) iterations. Rebalance is already rate-limited to onceper 120 s, so this costs a fraction of a second of latency while cutting the collective rate by
an order of magnitude. Every condition in
_can_pause_for_rebalanceis rank-uniform, whichkeeps the throttle counter — and therefore the iteration the collective runs on — identical
across ranks.
Attention DP: an OR-reduction, not an exclusion
An earlier revision of this PR excluded ADP from the agreement, on the argument that ADP
ranks own independent request streams and independent KV caches and so legitimately want
different ratios at different times. That argument holds for the ratios but not for the
timing, because the path the decision gates is itself collective under ADP:
_consume_previous_batch_for_rebalance→_flush_pending_transfer_responses→_enqueue_responses, which — as its own docstring requires — every DP rank must enter evenwith an empty response list, and which runs a
tp_gather.A rank rebalancing alone therefore joined a collective its peers were not in. Live on
Qwen3-Next-80B-A3B (tp4 = ep4, ADP, 4× H100) with only rank 0's
need_adjustmenttrue, thatgather paired against the
tp_allgatherof batch sizes in_can_queue:The TP hop now runs under ADP as
any(tp_allgather(need)).any()rather than rank 0'sbroadcast preserves the anti-starvation property the exclusion was reaching for: a rank that
needs a rebalance gets one without waiting for rank 0 to want the same thing. Same
configuration after the change: exit 0.
Two consequences worth weighing:
_can_pause_for_rebalancemust stayrank-uniform under ADP as well as under plain TP — the same requirement the non-ADP broadcast
already relies on, now extended.
adjust()stamps_last_adjustment_timeunconditionally, so a rank pulled into a peer'srebalance has its own 120 s cooldown reset by it. The group's cadence self-aligns, at the
price of deferring a need that arises immediately afterwards by up to one cooldown.
Cost on that model (2 pool groups, 17 active requests per rank): 21.1 ms for the rank that
needed it, 8.1 ms for a rank pulled in — of which ~7.3 ms is the pre-existing
torch.cuda.current_stream().synchronize()and 0.03 ms is the no-opadjust(). A check thatdecides not to rebalance costs 0.13-1.46 ms, against 0.09-1.68 ms for the
tp_broadcastalready on the non-ADP path.
CP
Orthogonal, and must not be skipped. Within one DP replica the CP ranks split the same
request along the sequence dimension, so they must rebalance together — including under ADP,
where the CP hop stays even though the TP hop changes shape.
PP: supported, by draining the ring rather than inline
_executor_loop_pphad no rebalance hook and_can_pause_for_rebalancerejectedpp_size > 1outright. The other two loops rebalance inline — at the top of an iteration at most one batch
is in flight and
_consume_previous_batch_for_rebalanceretires it on the spot. The PP loopkeeps up to
num_micro_batchesbatches in a ring, retired only as their sample state travelsthat ring, so there is no inline call that can drain it, and
adjust()cannot run while anyare outstanding because it moves KV pages underneath whatever holds them.
So: stop feeding the ring and let the loop drain itself. While a rebalance is pending the loop
forces
can_queueto False — its existing "skip this microbatch slot" path, the same one everyidle iteration already takes. Each iteration retires one slot and queues nothing new, so
num_micro_batchesiterations empty the ring, after which a new Stage 3.4 suspends, adjusts andresumes.
Every rank must do this on the same iteration — a rank that drained alone would desynchronize
the send/recv chain and hang the pipeline, a stronger requirement than the TP case where
divergence merely risks mismatched scheduling. Three things provide it:
_agreed_need_adjustmentgains a
pp_broadcasthop; the drain countdown is a rank-independent constant rather than aper-rank condition; and the ring state the quiescence test reads is already symmetric.
The PP hop is not suppressed under ADP, unlike the TP hop: ADP replicates along the TP
dimension, so a replica's pipeline stages all serve that replica's one request stream and must
drain together, and
pp_groupholds exactly those stages.If the ring is still busy when the countdown expires the rebalance is skipped with a warning
rather than forced — adjusting underneath a live
_KVCachewould corrupt it, while skippingcosts only a delay, since the tuner asks again on the next check interval.
Test Coverage
tests/unittest/_torch/multi_gpu/test_kv_pool_rebalance_tp.py(new) — 9 tests / 31parameterized cases on real MPI ranks with a real
MPIDist, following thetest_allgather.pyharness. Needs no model weights and no KV cache.
test_tp_ranks_agree_on_rebalance_triggertest_cp_ranks_agree_on_rebalance_triggertest_cp_and_tp_ranks_agree_on_rebalance_triggertest_pp_ranks_agree_on_rebalance_triggertest_tp_and_pp_ranks_agree_on_rebalance_triggertest_attention_dp_still_agrees_across_pp_stages[True, False, True, False]— the PP hop ran and the TP hop did nottest_attention_dp_ranks_or_coupletest_attention_dp_agrees_over_cp_then_ors_across_tptest_rebalance_check_stays_in_lockstep_across_ranksThe scenario that matters is
only_rank0_false: every follower's local clock says "rebalancenow" but rank 0's does not. The lockstep test drives the real collective inside the loop, so
a cadence divergence hangs rather than passing quietly.
tests/integration/test_lists/test-db/l0_dgx_h100.yml—unittest/_torch/multi_gpuwascollected by exactly one test-db entry, a 2-GPU stage, so 18 of the 31 cases could never
run in CI on any run:
tp:4,pp:4, CP×TP, TP×PP and both ADP compositions — precisely thenewest logic on this branch. They all reported
need 4 GPUs, have 2. The file is now named inthe 4-GPU PyTorch block as well.
tests/unittest/_torch/executor/test_kv_pool_rebalance.py— 21 → 57 tests, covering thethrottle, the agreement helper, the PP drain countdown, the quiescence predicate, the busy-ring
skip and the sample-stream sync.
tests/unittest/kv_cache_manager_v2_tests/test_kv_cache_manager_v2.py—test_dummy_kv_caches_do_not_feed_the_tuner. Asserts onneed_adjustmentrather than thecounters (neither has a getter on the C++ backend), opens the gates with the raw setters rather
than
force_rebalance_precondition(which would also skew the target and mask the thing undertest), and carries a control arm that closes an identically shaped cache without the
exclusion — without it the check would pass for the wrong reason. Mutation-tested: reverting
either guard fails it with the intended message.
tests/integration/defs/accuracy/test_kv_pool_rebalance_accuracy.py— the throttle wouldotherwise have made this test vacuous: it runs fewer iterations than a large interval, so
adjust()would never fire and the token comparison would pass while testing nothing. It nowun-throttles explicitly, and asserts the GPU pool ratio actually moved in the rebalance arm
and stayed fixed in the baseline arm. Verified by mutation:
Results
test_kv_pool_rebalance_tp.pytest_kv_pool_rebalance_tp.pyDGX_H100-2_GPUstest_kv_pool_rebalance.pytest_kv_pool_rebalance_accuracy.pykv_cache_manager_v2_testsOK (skipped=13)kv_cache_manager_v2_testsOK (skipped=13), 261 sL0_MergeRequest_PR #53654c21efaaMeasurements backing the cost claims
adjust()is bandwidth-bound; its cost is set by bytes in motion, not by pool-group count.Two points 31× apart on the same model and hardware (DeepSeek-V4-Flash, 3 pool groups, 4×
GB300): 5.50 GB in 91 ms and 171 GB in 2.15 s, both at 60-80 GB/s per rank. On Qwen3-Next-80B
(2 groups, 8× H100): 3.6 GB in 48 ms at ~75 GB/s.
One fully organic end-to-end rebalance was captured with no injection anywhere
(DeepSeek-V4, 12,288 prompts, ~3,060 real samples/rank, 477 s): it fired once across 376
hook calls, the ADP OR-reduction pulled all four ranks in on the same iteration, all four landed
on the same target, moved 169.36 GB in ~2.55 s, then converged and stopped.
sync+agreewas0.5-1.6 ms.
Known open items
adjust()re-checks neither the maturity gate nor the cooldown (kvCacheManager.cpp:862vs
:851), so once the agreement returns True every rank applies whatever target it iscarrying, including a rank whose own maturity gate had not opened. Measurement shows no harm
in the case that occurs — in the organic run every rank was mature and landed on the same
target — so this is a theoretical concern about ADP replicas drifting apart, not a
demonstrated one. Flagged for a codeowner's eye rather than fixed here.
Confirmed, not fixed, and orthogonal to this PR.
across the move. At the observed sequence shape the startup layout caps concurrency at ~67
sequences while one pool group holds slots for 47,841, and the tuner's target balances all
three at ~1,466 — but that is slot arithmetic, not a measured throughput result, and is not
quoted as one.
PR Checklist
Please review the following before submitting your PR:
PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.
PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.
Test cases are provided for new code paths (see test instructions)
If PR introduces API changes, an appropriate PR label is added - either
api-compatibleorapi-breaking. Forapi-breaking, includeBREAKINGin the PR title.Any new dependencies have been scanned for license and vulnerabilities
CODEOWNERS updated if ownership changes
Documentation updated as needed
Update tava architecture diagram if there is a significant design change in PR.
The reviewers assigned automatically/manually are appropriate for the PR.
Please check this after reviewing the above items as appropriate for this PR.
GitHub Bot Help
To see a list of available CI bot commands, please comment
/bot help.🤖 Generated with Claude Code