Skip to content

[TRTLLM-12891][feat] Support the KV cache connector on KVCacheManagerV2 - #18700

Merged
brb-nv merged 10 commits into
NVIDIA:feat/m3_with_msafrom
brb-nv:user/brb/yuehtingc/kvconn-v2-registration
Sep 4, 2026
Merged

[TRTLLM-12891][feat] Support the KV cache connector on KVCacheManagerV2#18700
brb-nv merged 10 commits into
NVIDIA:feat/m3_with_msafrom
brb-nv:user/brb/yuehtingc/kvconn-v2-registration

Conversation

@brb-nv

@brb-nv brb-nv commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Description

Cherry-pick of #17974.
Needed for mooncake integration: #18676

Test Coverage

N/A

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-compatible or api-breaking. For api-breaking, include BREAKING in 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.

eopXD added 10 commits September 3, 2026 21:00
…quest

The C++ base exposes is_generation_only_request as a read-only property
(def_prop_ro, nanobind/batch_manager/bindings.cpp), but the Python LlmRequest
subclass redefined it as a plain method with no @Property. Reading it as an
attribute therefore yields a bound method, which is always truthy.

V1 never trips this because the connector path reaches the attribute from C++
with the C++ object, where the property is real. Every Python-side reader gets
the method object instead, so `if request.is_generation_only_request` is
unconditionally true and the corresponding guard is dead.

Restore the property and drop the call parentheses at the six production call
sites and in the test mocks. A local shim was rejected: the same attribute is
reached from C++ with the C++ object and from Python with the Python one, so a
shim would have to sniff callable().

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
CacheTransceiverCpp is bound to the V1 BaseKVCacheManager, while
KVCacheManagerV2.impl is the Python V2 core's manager. The combination reached
BindKvCacheTransceiver and died on a raw nanobind signature mismatch that named
neither the manager nor the way out.

An equivalent check already existed for MambaHybridCacheManagerV2 but not for
its base class, so plain KVCacheManagerV2 fell straight through. The new check
sits after the hybrid one so the subclass keeps its more specific message; both
messages share the phrase the existing tests match on, so that ordering is
pinned by its own test.

This makes the failure legible; it does not make V2 work with a default
transceiver config. CacheTransceiverConfig.transceiver_runtime defaults to
"auto", which is resolved from the model's preference
(llm_utils._resolve_transceiver_runtime_auto) and knows nothing about which
cache manager will be built, so most models land on the C++ transceiver.
Working configuration is backend="NIXL" with transceiver_runtime="PYTHON".

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
Every connector test now runs twice, once per KV cache manager, so the V2
integration that follows has somewhere to land.

Selecting V2 must actually reach V2. _fallback_if_unsupported_kv_cache_manager_v2
silently substitutes the V1 manager for combinations it cannot serve, and a
connector test that ran on V1 while claiming to test V2 would pass while
exercising nothing. test_connector_runs_on_kv_cache_manager_v2 spies on both
managers' __init__ to prove positively which one was built, and asserts the
fallback warning is absent. assert_kv_caches_registered is the per-test half of
the same argument: the managers hand the worker their pools through different
entry points, and asserting the V1 one unconditionally would pass on V2 exactly
when the connector was never registered at all.

Parametrization is spelled out per test as an explicit
@pytest.mark.parametrize(..., indirect=True) rather than as a fixture params=,
because scripts/check_test_list.py resolves ids from decorators via AST and
cannot see fixture-level parametrization. Tests that supply their own
KvCacheConfig have the manager forced onto it, otherwise the V2 parametrization
degrades into a second V1 run.

Also replace the fixed time.sleep(1) barrier -- which carried its author's TODO
-- with a poll on the recorded mock call count until the connector goes quiet.
That returns as soon as the callbacks settle and stretches automatically when a
slower path lengthens the tail.

The one end-to-end test evaluated the generated text and discarded it; it now
asserts a cold miss, that cache files were written, a warm hit, and token
agreement. Comparing two deterministic runs proves nothing on its own -- they
agree whether or not the cache is consulted -- so the spy on the connector's
matched-token count is what makes it non-tautological. Agreement is a prefix
floor rather than equality: skipping prefill changes the attention reduction
order, so reuse can legitimately move the last token.

Every test id changed, so the CI list is retargeted onto the
kv_cache_manager_v1 variants. The kv_cache_manager_v2 variants fail until the
connector is implemented on V2, and are registered in no list until then.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
Connector bring-up hands the worker a single tensor from
get_unique_primary_pool. KVCacheManagerV2 has no such tensor and cannot grow
one: it allocates pool groups of slots, a slot is the set of coalesced buffers
belonging to one layer group, and there is one slot address space per pool and
one page-index space per layer group. The first hop of bring-up was therefore a
hard stop, and the fix is to describe the memory rather than point at it.

KvCacheLayout carries, per layer group, the byte ranges its pages live in: slot
i of a region is at base + stride * i for size bytes, or equivalently
region.as_tensor()[i]. build_kv_cache_layout_v2 is assembly of V2's own public
layout API -- layer_grouping, all_buffer_ids, get_aggregated_pages,
pool_group_descs -- so coalescing is derived from the allocator rather than
assumed by the consumer. For a uniform model a layer group's buffers merge into
a single whole-slot region, which is why the example connector's load and save
paths need no change. Regions are byte-oriented because one may span roles with
different element types, and a connector moving bytes should not have to care.

register_kv_cache_layout is non-abstract and raises by default, so existing
connectors are untouched on V1 and get an actionable error on V2 rather than a
crash.

A registered address is only valid while its page is pinned to GPU, and
eviction to another tier reassigns the page's slot, so reject any tier below
GPU. The resolved tier list is read from the manager rather than from
KvCacheConfig.host_cache_size, whose default of None is falsy but still yields
a host tier -- a truthiness check there is dead. That automatic tier is also
skipped outright when a connector is attached: it exists only to give the
MAX_UTILIZATION scheduler's suspend/resume somewhere to spill to, and a
connector run is already restricted to GUARANTEED_NO_EVICT, so it is dead
weight rather than a capability.

The VSWA guard in bring-up is relaxed for V2 in passing. It exists because the
single-tensor registration cannot describe one pool per window size, which is
the ordinary case for a layout. The creator-level gate above it still rejects,
and is dealt with separately.

Removing kv_connector_manager from the V2 incompatible-feature list left the
hybrid test asserting a reason that is no longer produced, so it now asserts V2
is returned unchanged; the operator-facing message no longer suggests disabling
the connector when the connector is not what makes the configuration
unsupported.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
Registration alone leaves the connector inert: it receives its pools but no
metadata, because its scheduler-side hooks are never invoked.

The two managers place allocation differently. V1 allocates in
KVCacheManager.prepare_resources and drives the connector from there. V2
allocates in KVCacheV2Scheduler, via prepare_context and resize_context, and
its prepare_resources is a no-op for the non-draft path -- so the hooks had no
home and build_connector_meta was never called. prepare_resources still runs
after scheduling and before the forward pass, and by then every scheduled
request has its pages, which is the same position in the iteration where V1
drives the connector. The hooks go there.

Page indices come from the manager rather than from the connector reaching into
kv_cache_map and impl.layer_grouping. They are reported per layer group and
positionally aligned: valid_only=False yields one entry per block ordinal, so a
block with no page in a group -- the sliding-window case -- reads back as
BAD_PAGE_INDEX in place rather than shortening the list. That is what keeps the
ordinal-to-token-range mapping intact and an append-delta over successive calls
valid. RequestData carries them as new_block_ids_by_layer_group; with a single
group -- every non-VSWA, non-hybrid model -- new_block_ids still carries that
group's indices, so connectors that do not reason about layer groups keep
working.

request_finished is routed through the same accessor. Without it the V1 lookup
raises, the surrounding warning path swallows the exception, and a connector on
V2 is never told to save anything.

Block hashes and retention priorities are reported empty rather than guessed at.
V2 has no per-request accessor for the hash chain, and KvCacheRetentionConfig
does not reach KVCacheManagerV2 at all -- per-page priority comes from
custom_priority_callback, which V2 never overrides, so every page carries the
default. Reporting those defaults would be worse than reporting nothing: a
connector doing priority-based offload filtering would act on values unrelated
to what the user configured.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
py_executor_creator refused any multi-valued max_attention_window before a
cache manager was ever chosen, so the V2 carve-out in PyExecutor below it was
unreachable and every multi-layer-group path was dead code.

VSWA allocates one pool per window size, which the V1 single-tensor
registration cannot describe. A KvCacheLayout can: it carries one region set
per layer group. So reject here only when V2 is definitively off.
kv_cache_config.use_kv_cache_manager_v2 is tri-state (True / False / "auto"),
and under "auto" the manager is not chosen yet, so defer -- PyExecutor
re-checks against the manager it actually built and rejects there if the
selection landed on V1. Layered rather than duplicated.

Two tests cover what this unblocks. A uniform sliding window, where every layer
shares one window: the registered layout must still collapse to a single layer
group and record the window. And VSWA, where V1 still raises and V2 reports two
groups with equal-length, positionally aligned index lists -- the flat
new_block_ids being empty is the correct answer there, since a page index is
scoped to a layer group and there is no correct way to flatten several.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
get_num_new_matched_tokens is a protocol, not a query: five of its six clauses
are obligations on the runtime. V1 discharges them in one step because its
entire connector interaction happens in prepare_resources, on the final batch.
V2 cannot. prepare_context runs during scheduling, and a prepared request can
still be dropped at the token budget, at resize_context, at multimodal
alignment or at cross attention. Rolling computed_position back on an iteration
the request never runs in is silent corruption: the next iteration arrives with
the position already advanced, the connector loads nothing, and the runtime
skips computing tokens whose KV is garbage.

V2 already separates match-and-take-ownership -- which needs only the token
sequence and cannot fail for lack of memory -- from residency, which claims
slots, migrates pages and can fail. The connector interaction is decomposed
along the same seam, with transmission as a third phase sharing the forward
pass's preconditions:

  phase 1  _connector_prefix_position   ask, before residency exists
  phase 2  _reserve_connector_prefix    cover the offer with pages, after resume
  phase 3  _deliver_connector_prefix    record the external load, on the batch

Phase 2 has to follow the resume because _KVCache.resize asserts the cache is
ACTIVE and a freshly created or deferred-and-suspended one is not. Capacity and
history move in one call: after a reuse match both equal the local match, so
raising history alone trips "History length cannot be greater than capacity".
Raising history is also what stops a served prefix allocating a page for every
block in a sliding-window layer group, since it is the sole input to the
stale-range computation. Phase 2 clears enable_swa_scratch_reuse for a served
prefix: scratch slots are transient prefill storage and a connector writes real
cache content into those blocks, the same reason the disagg generation path
opts out.

The memoised value is the absolute offer end, never the returned delta. A
deferred request re-derives its local match from a tree another request's
commit may have grown; adding the delta to the new match would place the
position past the union of what is computed and what is loaded, leaving tokens
that are neither. The offer end is also clamped below the prompt, because the
first generation step consumes the last prompt position's activations -- a
connector holding the whole prompt is the steady state of a repeat, not an
error.

KvCacheConnectorManager.get_num_new_matched_tokens is split into a
side-effect-free query and a commit that registers the async hold and the
external load. V1 keeps calling the fused entry point, which is correct there:
it is invoked from C++ under the block manager's tree mutex, so the match and
the query are atomic with respect to the tree and the answer can be committed
immediately.

cancel_load is additive with a no-op default. It hands an offer back when phase
2 cannot allocate, and when a request is asked and then cancelled, times out or
fails before delivery -- otherwise the connector holds remote blocks for the
life of the process with nothing left to release them.

should_add_sequence stays out of the V2 scheduler. That predicate is false from
the moment an asynchronous load completes until request_finished at the end of
generation; in the V2 scheduler it means SKIP, so V2 would skip such a request
forever and never run the prefill the load was for. What keeps a loading
request out of the batch is its DISAGG_GENERATION_TRANS_IN_PROGRESS state, and
what stops the connector being asked or told twice is the per-request state
machine.

Phase 3 asserts that what it records is covered by the position phase 2
advanced. Nothing downstream validates it: the runtime's subtraction is
unguarded and connectors divide the result into block ordinals, so a mismatch
would silently point a connector at the wrong offset inside its own code.

The unit suite models a suspended cache and drives the real
_prepare_context_impl rather than restating it, so the ordering is observed
rather than declared. Verified by mutation: moving the reserve above the resume
turns 17 of its tests red, where an earlier version of the same suite passed
all 24 against that same defect. A second suite drives the phases against a
real KVCacheManagerV2 with real pools and a deterministic deferral, which is
the only place the ask-once path is exercised end to end -- an engine-level
deferral test proved to be a race against the executor's request queue and was
removed rather than weakened.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
BAD_PAGE_INDEX was undocumented. Under a sliding window a connector on V2
receives -1 entries in update_state_after_alloc, RequestData.new_block_ids and
request_finished; one that treats them as page slots computes an address from
-1. State it, along with the reason the entry is kept in place rather than
dropped: ordinals stay aligned to token ranges and append-deltas stay valid.

Document cancel_load and the speculative scheduling pass it exists for, and
record what does not change -- get_num_new_matched_tokens is still called
exactly once per request on both managers, including across a deferral.

Correct its trigger list. The local match overtaking an offer cannot arise
today: a request's local match is fixed when its cache is created, resume()
does not re-match, and only the request's own completed forward passes extend
it. The two cases that can occur -- allocation failure, and a request freed
before delivery -- were not named at all.

Note that update_state_after_alloc covers only the first chunk's blocks under
chunked prefill on V2, since V2 allocates per chunk; the rest arrive as
append-deltas through build_connector_meta.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
The connector suite has run under both KV cache managers since it was
parametrized, but only the kv_cache_manager_v1 half was registered, behind a
comment saying the V2 variants were expected to fail. That is no longer true --
the connector is supported on V2 -- and coverage that runs only by hand decays
the first time someone touches _util.py or the bring-up path.

Register the 22 kv_cache_manager_v2 ids, plus the two that were never listed:
test_connector_runs_on_kv_cache_manager_v2, which is what makes the rest
meaningful (the creator silently falls back to the V1 manager for combinations
it cannot serve, so without it every V2 id could pass while running V1), and
the V1 half of the VSWA test.

test_connector_priorities[kv_cache_manager_v2] is marked xfail(strict=True)
rather than dropped from the list. KvCacheRetentionConfig does not reach
KVCacheManagerV2 at all, so a retention config is silently ignored there -- not
only through the connector. Its assertions stay the correct expectation for
both managers, so wiring retention into V2 turns the test green rather than
needing it rewritten, and strict=True makes that day loud instead of silent.

The host_offloading rejection assertion matched the bare string "host", which
both managers' messages contain, so it would have passed through exactly the
silent V1 fallback the parametrization exists to rule out. Match per manager
instead: V1 names the config field, V2 the resolved tier.

Retarget the static contract test. It was written as a Phase 2 worklist --
"remove entries as they are implemented" -- and that is not what happened:
KVCacheManagerV2 implements none of the V1 block-id methods and is not meant
to, because a flat pool-wide block id cannot describe memory whose page indices
are scoped to a layer group. What the check is actually worth keeping for is
that update_and_build_data reports block_hashes and priorities empty on V2 by
branching on the manager type, not on hasattr: those short-circuits are correct
only while the accessors are genuinely absent. It now says that, asserts the
V2-side contract it does have, and the disagg method list records why none of
its entries is a V2 gap rather than describing them as unported.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
The `prompt_len - 1` clamp in `_connector_prefix_position` shrank the offer
without telling the connector. `_deliver_connector_prefix` and
`_release_undelivered_connector_prefix` both read the already-clamped end,
so the tail past it reached neither, and the connector kept ownership of
those remote blocks for the life of the process -- the exact leak those two
paths exist to prevent. Cancel the dropped range where it is dropped, and
assert it in the two tests that exercise the clamp.

`_reject_non_gpu_cache_tiers` rejects every tier below GPU, which includes
a disk tier configured through `KvCacheConfig.disk_cache_size`, but told
the user to set `host_cache_size=0`. Name both fields.

Also add the missing NVIDIA header to `test_kv_connector_v2_prefix.py`.

Signed-off-by: Yueh-Ting Chen <yuehtingc@nvidia.com>
Signed-off-by: Balaram Buddharaju <169953907+brb-nv@users.noreply.github.com>
@brb-nv
brb-nv requested a review from StanleySun639 September 4, 2026 04:05
@brb-nv brb-nv changed the title User/brb/yuehtingc/kvconn v2 registration [TRTLLM-12891][feat] Support the KV cache connector on KVCacheManagerV2 Sep 4, 2026
@brb-nv

brb-nv commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #71431 [ run ] triggered by Bot. Commit: 5535069 Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #71431 [ run ] completed with state SUCCESS. Commit: 5535069
/LLM/main/L0_MergeRequest_PR pipeline #58536 completed with status: 'SUCCESS'

CI Report

Link to invocation

@brb-nv
brb-nv merged commit b6119f1 into NVIDIA:feat/m3_with_msa Sep 4, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants