Skip to content

[Bug]: PD P2P supply and demand are computed independently, and nothing checks they match #53083

Description

@liranschour

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 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.
return LookupResult.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:

  1. 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.
  2. server.py:372if 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.
  3. client.py:229assert 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 scheduler.py:1312 (is_store_reachable_swa_chunk, :129)
3 producer EAGLE/MTP volatile trailing chunk while decoding scheduler.py:404 (in storable_chunks, :382)
4 producer prepare_store returns None (primary allocation failure) scheduler.py:1326
5 producer prefix-hit key that is not HIT in primary at cascade time tiering/manager.py:615#53062
6 producer max_offload_tokens / offload_prompt_only truncation scheduler.py:588
7 consumer _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:

Also worth doing, not proposed here

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.
  • Demand-driven serving for the PD leg, as symmetric P2P already has. This
    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...

  • Make sure you already searched for relevant issues, and asked the core devs or team in the vLLM Forum first

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions