You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In the PD (prefill/decode disaggregated) flow over the P2P secondary tier, the
producer's supply and the consumer's demand are computed by two
independent code paths on two different engines, and nothing anywhere verifies
that they agree. The protocol depends on them agreeing. When they diverge, the
consumer's unmatched demand parks until _LOAD_TIMEOUT_S = 30.0
(vllm/v1/kv_offload/tiering/p2p/session/client.py:33) and it recomputes the
prompt.
#52808 and #53062 are two instances of this. They were found separately, have
different triggers, and were fixed (or are being fixed) separately. Six more
divergence sites exist and are unguarded. This issue names the invariant and
proposes a test mechanism that fails CI when a future change breaks it.
Line numbers for source files are against main @ 5a4c8d9924. Line numbers
for tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py are
against #52912's branch (657fcc1b4), since the tests referenced below are part
of that PR and are not on main yet.
Why PD has no enforcement, and symmetric P2P does
In symmetric P2P the consumer sends a LookupMsg, the server answers from
tier lookups and pins, and only then does the consumer fetch. Supply is derived from demand, so they cannot disagree — a warm producer is the best
case.
The PD leg has no such round trip. P2PSecondaryTierManager.lookup for a
PD consumer is:
# PD consumer (we are the decoder): all kv blocks should be on the# prefiller side. Return HIT immediately.returnLookupResult.HIT
— vllm/v1/kv_offload/tiering/p2p/manager.py:350
The decoder asserts by construction that every block it wants is on the
prefiller. It never checks. Meanwhile the prefiller independently decides what
to store, through a chain of filters that know nothing about what the decoder
will ask for. The invariant "producer supply ⊇ consumer demand" is load-bearing
and unenforced.
There is no cross-check today — not even an observation
I looked for any assertion, metric, counter, or log that compares the two key
sets. There is none. The P2P tier emits no metrics at all. The three closest
signals all fall short, and two of them are dead code in PD:
server.py:966-979 — the only place both sides of the mismatch appear
together, as a debug log: remaining (demanded, never supplied) next to leftover_available (supplied, never demanded). Counts only, no keys, off by
default.
server.py:372 — if req.lookup_supplied and req.demanded: warns and
fails the fetch immediately rather than letting the peer hit the load
timeout. lookup_supplied is only ever set by the symmetric lookup-pin path,
so this never fires in PD — as its own comment says.
client.py:229 — assert all(st.probes.get(key) is True for key in keys),
guarded by st.probed, which PD never sets. Also dead in PD.
So the two guards that would catch a mismatch are both gated on symmetric-only
state. In PD, a mismatch surfaces only as an eventual TransferDoneMsg(success=False) or a 30s timeout, in both cases without naming
which keys were missing. #52808 shows the practical cost: the timeout path
prints a PYTHONHASHSEED hint that had nothing to do with the failure, and the
reporter had to verify and rule it out.
The divergence surface
Each row alone is sufficient to break a round. Producer-side rows drop keys the
consumer will demand; consumer-side rows demand keys the producer will not supply.
#
Side
What drops / over-demands
Location
1
producer
if block_id == 0: continue — null-block sentinel
offloading/scheduler.py:1304
2
producer
SWA chunk unreachable within its alignment segment
_sliding_window_lookup promotes every key it scans, not just the final window, so out-of-window chunks enter the FetchMsg
scheduler.py:633 + tiering/manager.py:404
8
consumer
eager unconditional HIT, no existence check
p2p/manager.py:350
Plus the policy gate that caused #52808: under BLOCK_LEVEL, scheduler.py:1075 sets next_stored_chunk_idx = num_chunks, skipping
prefix-hit chunks entirely. Fixed for the producer leg by #52912.
Only #5 and the policy gate are being addressed. Rows 1, 2, 3 are currently
consistent with the demand side, but only because two hand-mirrored pieces of
logic happen to agree — which is exactly the fragility below.
Why this needs a mechanism rather than another point fix
#51840 changed lookup to return HIT_PENDING instead of RETRY when a
promotion is initiated. In _sliding_window_lookup, HIT_PENDING counts toward
the consecutive-hit streak while RETRY resets it. So a change to lookup semantics, in a different file, silently changed which keys the consumer
demands — widening it on v0.27.1 to include 55 out-of-window SWA chunks a
warm producer can never supply. Nothing in the test suite noticed; the divergence
was found by hand-instrumenting a container and counting chunks
(#52912 (comment)).
That is the regression class to guard: the invariant spans two engines and half a
dozen files, so any single-file change can break it invisibly.
Proposed guard: exhaustiveness sentinels
The suite already has the right pattern. _SCAN_BEHAVIOR in tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py:1252 is a
table keyed by every LookupResult, with test_scan_behavior_declared_for_every_lookup_result parametrized over list(LookupResult) — a new enum member fails the suite until someone declares
its behavior. That is what makes a guard survive future commits instead of
pinning today's bugs.
Three sentinels in the same style would cover the surface above:
1. _CASCADE_SUPPLY_BEHAVIOR: dict[LookupResult, Disposition] — guards row 5.
For each LookupResult the primary tier can return at cascade time, declare SUPPLY / DEFER / DROP. Drive a real TieringOffloadingManager with a
stubbed primary returning the parametrized result plus a recording request-level
tier, and assert the observed disposition matches the declared one. Today: HIT → SUPPLY, HIT_PENDING / RETRY / MISS → DROP. When #53062 lands, HIT_PENDING flips to DEFER and the table records that as deliberate intent
rather than a coincidence. Reuses MetricsSecondaryTierManager
(tests/v1/kv_offload/tiering/test_tiering_offloading.py:99) and the existing manager_setup fixture.
2. _POLICY_KEEPS_PREFIX_HITS: dict[OffloadPolicy, bool] — guards the #52808
gate at scheduler.py:1075. For each OffloadPolicy, declare whether prefix-hit
chunks stay in the store path; drive request_runner over a warm prefix and
assert. A future policy has to declare, and if it declares False, assert the
P2P tier does not select it for a producer leg.
3. _GROUP_STORE_PRUNING: dict[type[KVCacheSpec], ...] — guards rows 1, 2, 3
and consumer over-demand 7. get_sliding_window_size_in_chunks
(scheduler.py:109) already switches on spec type — SlidingWindowSpec, ChunkedLocalAttentionSpec, MambaSpec, FullAttentionSpec — and closes with assert isinstance(kv_cache_spec, FullAttentionSpec). So a new attention type
does fail closed, but only at runtime on a model that uses it, never in CI. The
sentinel enumerates the handled spec types, requires each to declare whether the
store path prunes and which demand scan the load path uses, and for every
pruning type asserts the relation that test_sliding_window_demand_is_store_reachable (test_scheduler.py:1517)
already checks for SWA.
These are pure-CPU unit tests needing no new harness.
What already exists, and where it stops
#52912 added test_request_level_supply_covers_consumer_demand
(test_scheduler.py:2365), which asserts the invariant at the scheduler level
over warmth × with_swa_group. Two limits worth recording:
Supply is measured at prepare_store call args against a MagicMock
manager, so everything downstream — the cascade's HIT-only filter (row 5), submit_store, and the session parking in _OutboundRequestState — is
invisible to it. [Bugfix][KVOffload] Request-level cascade silently drops HIT_PENDING keys #53062 lives entirely in that stretch.
Listing these so they are not lost, in rough order of value:
Tier-level supply invariant — the same assertion with a real TieringOffloadingManager, so the cascade filter is in scope.
Demand-oracle drift guard — pin _demanded_keys against a real consumer
leg's actual lookups, so the oracle cannot drift silently.
Make the runtime failure loud — promote the server.py:966-979 debug log
and name the unmatched keys with their KV cache group indices
(get_offload_group_idx), so a mismatch is diagnosable from a single log line
instead of an instrumented rebuild, and so the misleading PYTHONHASHSEED
hint stops being the headline.
I have the sentinel tests locally and am glad to open the PR, or to hand the
patch to whoever is already in this code — @almogtavor and @AbarnaaSree both
volunteered on #53062, and the two overlap.
AI assistance was used to survey the code paths and draft this issue; the
analysis and every cited location were reviewed and verified by hand.
Before submitting a new issue...
Make sure you already searched for relevant issues, and asked the core devs or team in the vLLM Forum first
Summary
In the PD (prefill/decode disaggregated) flow over the P2P secondary tier, the
producer's supply and the consumer's demand are computed by two
independent code paths on two different engines, and nothing anywhere verifies
that they agree. The protocol depends on them agreeing. When they diverge, the
consumer's unmatched demand parks until
_LOAD_TIMEOUT_S = 30.0(
vllm/v1/kv_offload/tiering/p2p/session/client.py:33) and it recomputes theprompt.
#52808 and #53062 are two instances of this. They were found separately, have
different triggers, and were fixed (or are being fixed) separately. Six more
divergence sites exist and are unguarded. This issue names the invariant and
proposes a test mechanism that fails CI when a future change breaks it.
Line numbers for source files are against
main@5a4c8d9924. Line numbersfor
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.pyareagainst #52912's branch (
657fcc1b4), since the tests referenced below are partof that PR and are not on
mainyet.Why PD has no enforcement, and symmetric P2P does
In symmetric P2P the consumer sends a
LookupMsg, the server answers fromtier lookups and pins, and only then does the consumer fetch. Supply is
derived from demand, so they cannot disagree — a warm producer is the best
case.
The PD leg has no such round trip.
P2PSecondaryTierManager.lookupfor aPD consumer is:
—
vllm/v1/kv_offload/tiering/p2p/manager.py:350The decoder asserts by construction that every block it wants is on the
prefiller. It never checks. Meanwhile the prefiller independently decides what
to store, through a chain of filters that know nothing about what the decoder
will ask for. The invariant "producer supply ⊇ consumer demand" is load-bearing
and unenforced.
There is no cross-check today — not even an observation
I looked for any assertion, metric, counter, or log that compares the two key
sets. There is none. The P2P tier emits no metrics at all. The three closest
signals all fall short, and two of them are dead code in PD:
server.py:966-979— the only place both sides of the mismatch appeartogether, as a
debuglog:remaining(demanded, never supplied) next toleftover_available(supplied, never demanded). Counts only, no keys, off bydefault.
server.py:372—if req.lookup_supplied and req.demanded:warns andfails the fetch immediately rather than letting the peer hit the load
timeout.
lookup_suppliedis only ever set by the symmetric lookup-pin path,so this never fires in PD — as its own comment says.
client.py:229—assert all(st.probes.get(key) is True for key in keys),guarded by
st.probed, which PD never sets. Also dead in PD.So the two guards that would catch a mismatch are both gated on symmetric-only
state. In PD, a mismatch surfaces only as an eventual
TransferDoneMsg(success=False)or a 30s timeout, in both cases without namingwhich keys were missing. #52808 shows the practical cost: the timeout path
prints a
PYTHONHASHSEEDhint that had nothing to do with the failure, and thereporter had to verify and rule it out.
The divergence surface
Each row alone is sufficient to break a round. Producer-side rows drop keys the
consumer will demand; consumer-side rows demand keys the producer will not supply.
if block_id == 0: continue— null-block sentineloffloading/scheduler.py:1304scheduler.py:1312(is_store_reachable_swa_chunk,:129)scheduler.py:404(instorable_chunks,:382)prepare_storereturnsNone(primary allocation failure)scheduler.py:1326HITin primary at cascade timetiering/manager.py:615— #53062max_offload_tokens/offload_prompt_onlytruncationscheduler.py:588_sliding_window_lookuppromotes every key it scans, not just the final window, so out-of-window chunks enter theFetchMsgscheduler.py:633+tiering/manager.py:404HIT, no existence checkp2p/manager.py:350Plus the policy gate that caused #52808: under
BLOCK_LEVEL,scheduler.py:1075setsnext_stored_chunk_idx = num_chunks, skippingprefix-hit chunks entirely. Fixed for the producer leg by #52912.
Only #5 and the policy gate are being addressed. Rows 1, 2, 3 are currently
consistent with the demand side, but only because two hand-mirrored pieces of
logic happen to agree — which is exactly the fragility below.
Why this needs a mechanism rather than another point fix
#51840 changed
lookupto returnHIT_PENDINGinstead ofRETRYwhen apromotion is initiated. In
_sliding_window_lookup,HIT_PENDINGcounts towardthe consecutive-hit streak while
RETRYresets it. So a change to lookupsemantics, in a different file, silently changed which keys the consumer
demands — widening it on
v0.27.1to include 55 out-of-window SWA chunks awarm producer can never supply. Nothing in the test suite noticed; the divergence
was found by hand-instrumenting a container and counting chunks
(#52912 (comment)).
That is the regression class to guard: the invariant spans two engines and half a
dozen files, so any single-file change can break it invisibly.
Proposed guard: exhaustiveness sentinels
The suite already has the right pattern.
_SCAN_BEHAVIORintests/v1/kv_connector/unit/offloading_connector/test_scheduler.py:1252is atable keyed by every
LookupResult, withtest_scan_behavior_declared_for_every_lookup_resultparametrized overlist(LookupResult)— a new enum member fails the suite until someone declaresits behavior. That is what makes a guard survive future commits instead of
pinning today's bugs.
Three sentinels in the same style would cover the surface above:
1.
_CASCADE_SUPPLY_BEHAVIOR: dict[LookupResult, Disposition]— guards row 5.For each
LookupResultthe primary tier can return at cascade time, declareSUPPLY/DEFER/DROP. Drive a realTieringOffloadingManagerwith astubbed primary returning the parametrized result plus a recording request-level
tier, and assert the observed disposition matches the declared one. Today:
HIT → SUPPLY,HIT_PENDING / RETRY / MISS → DROP. When #53062 lands,HIT_PENDINGflips toDEFERand the table records that as deliberate intentrather than a coincidence. Reuses
MetricsSecondaryTierManager(
tests/v1/kv_offload/tiering/test_tiering_offloading.py:99) and the existingmanager_setupfixture.2.
_POLICY_KEEPS_PREFIX_HITS: dict[OffloadPolicy, bool]— guards the #52808gate at
scheduler.py:1075. For eachOffloadPolicy, declare whether prefix-hitchunks stay in the store path; drive
request_runnerover a warm prefix andassert. A future policy has to declare, and if it declares
False, assert theP2P tier does not select it for a producer leg.
3.
_GROUP_STORE_PRUNING: dict[type[KVCacheSpec], ...]— guards rows 1, 2, 3and consumer over-demand 7.
get_sliding_window_size_in_chunks(
scheduler.py:109) already switches on spec type —SlidingWindowSpec,ChunkedLocalAttentionSpec,MambaSpec,FullAttentionSpec— and closes withassert isinstance(kv_cache_spec, FullAttentionSpec). So a new attention typedoes fail closed, but only at runtime on a model that uses it, never in CI. The
sentinel enumerates the handled spec types, requires each to declare whether the
store path prunes and which demand scan the load path uses, and for every
pruning type asserts the relation that
test_sliding_window_demand_is_store_reachable(test_scheduler.py:1517)already checks for SWA.
These are pure-CPU unit tests needing no new harness.
What already exists, and where it stops
#52912 added
test_request_level_supply_covers_consumer_demand(
test_scheduler.py:2365), which asserts the invariant at the scheduler levelover
warmth × with_swa_group. Two limits worth recording:prepare_storecall args against aMagicMockmanager, so everything downstream — the cascade's
HIT-only filter (row 5),submit_store, and the session parking in_OutboundRequestState— isinvisible to it. [Bugfix][KVOffload] Request-level cascade silently drops HIT_PENDING keys #53062 lives entirely in that stretch.
_demanded_keys(test_scheduler.py:2343), whichreimplements the scan on a bypass scheduler. It can drift along with
production rather than pinning it — the [Bugfix][TieredOffloading] : Return HIT_PENDING when KV promotion is triggered #51840 failure mode.
Also worth doing, not proposed here
Listing these so they are not lost, in rough order of value:
TieringOffloadingManager, so the cascade filter is in scope._demanded_keysagainst a real consumerleg's actual lookups, so the oracle cannot drift silently.
server.py:966-979debug logand name the unmatched keys with their KV cache group indices
(
get_offload_group_idx), so a mismatch is diagnosable from a single log lineinstead of an instrumented rebuild, and so the misleading
PYTHONHASHSEEDhint stops being the headline.
removes the invariant rather than guarding it, and was raised as the long-term
direction in the [Bugfix][KVOffload] P2P tier declares REQUEST_LEVEL on the producer leg #52912 discussion (cc @Etelis @nilig @ronensc @orozery).
Happy to hand over
I have the sentinel tests locally and am glad to open the PR, or to hand the
patch to whoever is already in this code — @almogtavor and @AbarnaaSree both
volunteered on #53062, and the two overlap.
AI assistance was used to survey the code paths and draft this issue; the
analysis and every cited location were reviewed and verified by hand.
Before submitting a new issue...