Skip to content

[Bugfix][Core] Report the unreported ancestor of a partial prefix-cache entry - #58383

Closed
apex-mochen wants to merge 1 commit into
vllm-project:mainfrom
apex-mochen:fix/partial-cache-skipped-ancestry
Closed

apex-mochen wants to merge 1 commit into
vllm-project:mainfrom
apex-mochen:fix/partial-cache-skipped-ancestry

Conversation

@apex-mochen

@apex-mochen apex-mochen commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Make the KV-cache event stream reconstructable when a partial prefix-cache entry
references a parent hash that no event reported.

Fixes #58119

Problem

An external consumer of the KV-cache event stream cannot attach a partial entry
to its prefix chain, because the entry's parent may never have been emitted.

With hash_block_size=128, block_size=1536 and an 1800-token request, the
emitted events are:

{"block_hashes": ["H1536"], "parent_block_hash": null,    "token_ids": [0, 1536),    "block_size": 1536}
{"block_hashes": ["H1792"], "parent_block_hash": "H1664",  "token_ids": [1664, 1792), "block_size": 128}

H1664 is reported by no event, and the second event carries nothing else that
lets a consumer place it in the chain.

Root Cause

A granularity mismatch between the two event producers in
vllm/v1/core/block_pool.py:

  • cache_full_blocks() reports block_hashes from
    resolve_block_hashes(..., block_size) — one hash per cache block
    (1536 tokens), so only H1536 is reported.
  • cache_partial_block() describes a hash-block-sized chunk: its parent comes
    from _get_partial_block_parent_hash_and_start(), so H1792 names the
    fine-grained boundary H1664, which nothing reports.

The partial event's fine-grained shape must stay as it is: it is pinned by
test_cache_partial_block_kv_cache_events.

Solution

Emit the boundary (or boundaries) between the last reported block-aligned
ancestor and the partial entry, so every parent reference in the stream resolves
to a reported hash. For the example above the stream becomes:

{"block_hashes": ["H1536"], "parent_block_hash": null,    "token_ids": [0, 1536)}
{"block_hashes": ["H1664"], "parent_block_hash": "H1536", "token_ids": [1536, 1664)}
{"block_hashes": ["H1792"], "parent_block_hash": "H1664", "token_ids": [1664, 1792)}

Two deliberate restrictions:

  • No event schema change. See "Rejected alternative" below.
  • Only first-time partial registrations are filled. A promotion has already
    described its own boundaries and reports their removal, so re-reporting them
    would resurrect hashes that were just removed; that case would also change the
    event sequence pinned by
    test_partial_block_replacement_emits_remove_then_store_events. Nothing is
    emitted when the entry lies in the first cache block, because there is no
    reported ancestor to attach to.

Prior art in tree

OffloadingEventsTracker._build_partial_tail_metadata()
(vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py) already
solves the same problem for a partial tail, and does it by making the ancestry
explicit inside the event:

chunk_start = ((boundary_tokens - 1) // tokens_per_chunk) * tokens_per_chunk
first_hash_idx = chunk_start // tokens_per_hash
last_hash_idx = boundary_tokens // tokens_per_hash
block_hashes = req.block_hashes[first_hash_idx:last_hash_idx]   # every hash in the span
parent_block_hash = req.block_hashes[first_hash_idx - 1] if first_hash_idx > 0 else None
assert first_hash_idx == 0 or parent_block_hash is not None

For the configuration in this report that produces
block_hashes=[H1664, H1792] with parent_block_hash=H1536, so the chain closes
with no extra events. The core's partial entry cannot adopt that shape without
changing test_cache_partial_block_kv_cache_events, which pins a single-element
block_hashes and a fine-grained parent.

A second implementation already does what this patch does.
SimpleCPUOffloadManager (vllm/v1/simple_kv_offload/manager.py, ~line 903)
emits one event per fine-grained boundary inside a stored group block, each
covering exactly one hash block and parented to the immediately preceding
boundary:

first_hash_idx = token_start // self.hash_block_size
last_hash_idx = token_end // self.hash_block_size
for hash_idx in range(first_hash_idx, last_hash_idx):
    ...
    meta_by_hash[block_hash] = BlockStoreMeta(
        token_ids=list(request.all_token_ids[hash_start:hash_end]),
        parent_block_hash=(
            None if hash_idx == 0
            else maybe_convert_block_hash(request.block_hashes[hash_idx - 1])
        ),
        block_size=self.hash_block_size,
    )

That is the same shape as the events this patch adds for the core's partial entry,
so the core's stream ends up consistent with what the offload path already
produces for the same tokens.

(For completeness: mooncake/store/worker.py derives its parent from the request's
hash chain and its own comment notes that "Store filtering can separate adjacent
request blocks", so the same class of gap is already recognised in the connector
layer; it is not touched here.)

Rejected alternative

Adding optional skipped_parent_block_hash / skipped_token_ids fields to
BlockStored and pointing parent_block_hash at the last reported ancestor.
This was implemented and abandoned, because it silently changes the wire format
for every event: BlockStored had 14 declared fields and a tagged struct
encodes as a map, so msgspec sized the header from the declared field count. At
15 entries (14 fields + tag) it fits msgpack's one-byte fixmap header; adding
two fields pushes it to 17 and every payload switches to a three-byte map16
header:

old: \x8c          (fixmap, 12 entries) -> 221 bytes
new: \xde\x00\x0c  (map16,  12 entries) -> 223 bytes

tests/distributed/test_kv_cache_events.py exists precisely to prevent that
(_LegacyBlockStored, _PreSessionBlockStored), and it failed for the two
pre-existing wire-compatibility tests under that approach — which is how this
was caught. The schema-free version keeps those tests green.

Testing

VLLM_TARGET_DEVICE=cpu pytest \
  tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py \
  tests/distributed/test_kv_cache_events.py
Suite Result
tests/distributed/test_kv_cache_events.py (wire format) 17 passed — unchanged from main, no schema change
tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py 17 passed, incl. test_cache_partial_block_kv_cache_events and test_partial_block_replacement_emits_remove_then_store_events
3 new tests pass

New tests:

  • test_partial_block_event_reports_unreported_ancestry — reproduces the
    reported configuration, asserts the emitted sequence and token ranges, and
    asserts the invariant that every parent reference resolves to a reported
    hash.
  • test_partial_block_event_reports_every_unreported_boundary — several hash
    boundaries can sit between the last reported ancestor and the entry; asserts
    all of them are reported and that the reported token ranges tile the prefix
    without gaps.
  • test_partial_block_in_first_cache_block_reports_nothing_extra — the first
    cache block has no reported ancestor, so no boundary is invented and the
    partial entry remains the only event.

tests/v1/core/prefix_cache/test_hybrid_prefix_cache_hit_rate.py cannot run
here: it starts a RemoteOpenAIServer and downloads Qwen/Qwen3.5-0.8B, so its
fixture errors are environmental.

Known remaining case (deliberately not addressed here)

The same bug class also exists on the promotion path. When an existing partial
entry moves to a later boundary, the pool emits BlockRemoved for the old
boundary and a BlockStored whose parent_block_hash is that removed hash, so
the parent is again unresolved in the stream:

Removed hashes=['0xb92e76c1']
Stored  hashes=['0xdeab6e27'] parent=0xb92e76c1
parent reported in this batch? False

It is not fixed here on purpose. The current behaviour is pinned by
test_partial_block_replacement_emits_remove_then_store_events, which asserts
both len(events) == 2 and that the stored event's parent is the removed
boundary. Changing it requires deciding whether a promotion should re-report the
boundary it just removed or instead point its parent at the last surviving
ancestor. Tracked separately as #58394 (with a runnable repro and its actual
output), so it does not widen this PR's scope — happy to fold it in here if you
would prefer that.

Compatibility

No schema change, so payloads are byte-for-byte identical to main — verified by
the existing wire-compatibility tests. The only observable difference is the
additional BlockStored events for boundaries that were previously missing from
the stream, which is the point of the fix.

AI assistance disclosure

The investigation, patch and tests in this PR were produced with AI tooling
(DeepSeek Harness). I reviewed every changed line, reproduced the reported event
stream locally, and ran the tests above.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify mergify Bot added bug Something isn't working kv-cache-manager labels Sep 23, 2026
@apex-mochen
apex-mochen force-pushed the fix/partial-cache-skipped-ancestry branch from 9d919bd to d76f4e4 Compare September 23, 2026 15:32
@apex-mochen apex-mochen changed the title [Bugfix][Core] Report skipped ancestry in partial prefix-cache events [Bugfix][Core] Report the unreported ancestor of a partial prefix-cache entry Sep 23, 2026
…he entry

A partial prefix-cache entry is keyed at ``hash_block_size`` granularity while
``cache_full_blocks()`` reports hashes at ``block_size`` granularity, so a
first-time partial registration can name a parent that no event ever reported
and an external consumer cannot attach the entry to its prefix chain.

With ``hash_block_size=128`` and ``block_size=1536`` for an 1800-token request,
the stream reports ``H1536`` (full block) and ``H1792`` (partial entry) with
``H1792``'s parent set to ``H1664``, which is never reported.

Emit the boundary or boundaries between the last reported block-aligned ancestor
and the partial entry, so every parent reference in the stream resolves to a
reported hash.

The event schema is deliberately left unchanged. Adding optional fields to
``BlockStored`` was implemented first and abandoned: it pushed the struct's
declared field count past msgpack's fixmap boundary, so every ``BlockStored``
payload grew a 3-byte ``map16`` header instead of a 1-byte ``fixmap`` header.
``tests/distributed/test_kv_cache_events.py`` exists to forbid exactly that kind
of change to the wire format.

Only first-time partial registrations are filled in. A promotion has already
described its own boundaries and reports their removal, so re-reporting them
would resurrect hashes that were just removed.

Fixes vllm-project#58119

Co-authored-by: DeepSeek Harness <noreply@deepseek.com>
Signed-off-by: 叮当猫 <2756823972@qq.com>
@apex-mochen

Copy link
Copy Markdown
Contributor Author

Reviewed the two changed files and ran both suites locally (17 + 17 passed). Happy to adjust if you'd prefer a different shape for the partial-entry ancestry.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Author verification

Re-ran on the branch head efe5538439 (minutes before posting this):

pytest tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py -q
# 17 passed

pytest tests/distributed/test_kv_cache_events.py -q
# 17 passed

Environment: WSL2 Ubuntu 24.04, CPU only (this machine has no GPU), run with VLLM_TARGET_DEVICE=cpu so current_platform resolves to CpuPlatform, and PYTHONPATH pointed at the checkout.

The kv-cache event suite passing unmodified matters for this change: the new events are
emitted at the boundary rather than by widening BlockStored, so the encoded payloads
stay byte-identical to main. An earlier draft of this fix added fields to
BlockStored and that suite caught the wire-format break, which is why the shipped
version does not touch the schema.

Posting these numbers for the record. The runs were executed with AI assistance (DeepSeek Harness), consistent with the disclosure in the PR description; commands above are reproducible as written.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Closing this as a duplicate of #58121, which covers the same defect in the same
function and reports the same scenario -- I had not searched open PRs on
block_pool.py before opening it, which is my mistake.

For the record, the two are the same case: cache_partial_block() registers only
the final hash boundary, and the BlockStored event names an intermediate
boundary that no event reports. My reproduction produced exactly the numbers in
#58121's description (H1536 resident, H1664 omitted, H1792 partial), and I
took the schema-free route -- emitting the skipped boundary as its own event --
rather than populating the skipped_* metadata introduced by #44488. Since
#58121 is stacked on that in-flight PR, it is the one aligned with the direction
of travel, so it should be the one that lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working kv-cache-manager

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Partial cache events can reference unreported parent hashes

1 participant