Skip to content

fix(kvcache): decode vLLM map-format KV events; tier-aware prefix scoring - #2587

Open
wuyan-zs wants to merge 1 commit into
vllm-project:mainfrom
wuyan-zs:fix/2285-kv-event-decoder-map-format
Open

fix(kvcache): decode vLLM map-format KV events; tier-aware prefix scoring#2587
wuyan-zs wants to merge 1 commit into
vllm-project:mainfrom
wuyan-zs:fix/2285-kv-event-decoder-map-format

Conversation

@wuyan-zs

Copy link
Copy Markdown

Summary

Fixes #2285. While investigating, I found a larger compatibility break:
vLLM switched KV event encoding from positional arrays to msgpack maps in
vllm-project/vllm#42892
(merged 2026-06-09). DecodeEventBatch still expected [ts, events] arrays,
so every KV event payload from current vLLM fails to unmarshal
(msgpack: invalid code=0x88 decoding array length) — kv-events prefix
routing is effectively dead against current vLLM. The legacy publisher also
sent one event per ZMQ message (tag-first array), which the batch-shaped
decoder never handled either.

Changes

  • pkg/cache/kvcache/msgpack_decoder.go: accept all three wire shapes —
    map single event (current vLLM), tag-first array single event (legacy
    vLLM), and [ts, events] batch (older code paths / test encoder).
  • Parse every BlockStored/BlockRemoved field: lora_id, medium,
    lora_name, extra_keys, group_idx, kv_cache_spec_kind,
    kv_cache_spec_sliding_window, locality.
  • pkg/utils/syncprefixcacheindexer: record the storage medium per
    (prefix, pod) and weight MatchPrefix scores (GPU=1.0, CPU=0.5,
    STORAGE=0.25; unspecified keeps full score for backward compatibility)
    so pods holding a prefix only on a slower tier are not over-credited.
  • Plumb medium/lora_name/group_idx through the kvevent handler and
    store_providers adapter (previously dropped at the handler).

Tests

New decoder_vllm_compat_test.go verifies decoding against byte-exact
payloads generated with the real msgspec encoder
for both current map and
legacy array formats (single-event and batch shapes). New
medium_scoring_test.go covers tier weighting via AddPrefixWithMedium and
the event ingestion path. All existing kvcache and
syncprefixcacheindexer tests keep passing.

Follow-up

The (model, lora_name, group_idx) index keying part of #2285 needs a
request-side adapter id (RoutingContext has no LoRA field today); see the
issue thread.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request adds compatibility with newer vLLM wire formats (including msgspec map encoding) and introduces tier-aware scoring (GPU, CPU, STORAGE) for prefix cache routing. Feedback on the changes highlights a potential decoding failure in decodeBatchArray due to a strict float64 type assertion on the timestamp, as well as issues where a pod's storage medium is not updated when the pod already exists in the prefix store, leading to stale tier information.

Comment on lines +119 to 122
tsFloat, ok := arr[0].(float64)
if !ok {
return nil, fmt.Errorf("invalid batch timestamp type: %T", rawBatch[0])
return nil, fmt.Errorf("invalid batch timestamp type: %T", arr[0])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In decodeEventArray, the switch statement accepts various numeric types for the timestamp (e.g., int, int64, float32, etc.). However, inside decodeBatchArray, there is a strict type assertion to float64 (tsFloat, ok := arr[0].(float64)). If the timestamp is decoded as any other numeric type, this assertion will fail, resulting in a decoding error. We should use a type switch to robustly convert any of the accepted numeric types to float64.

var tsFloat float64
	switch x := arr[0].(type) {
	case float64:
		tsFloat = x
	case float32:
		tsFloat = float64(x)
	case int:
		tsFloat = float64(x)
	case int8:
		tsFloat = float64(x)
	case int16:
		tsFloat = float64(x)
	case int32:
		tsFloat = float64(x)
	case int64:
		tsFloat = float64(x)
	case uint:
		tsFloat = float64(x)
	case uint8:
		tsFloat = float64(x)
	case uint16:
		tsFloat = float64(x)
	case uint32:
		tsFloat = float64(x)
	case uint64:
		tsFloat = float64(x)
	default:
		return nil, fmt.Errorf(\"invalid batch timestamp type: %T\", arr[0])
	}

} else {
pods[podName] = &PodInfo{
SourcePod: podName,
Medium: medium,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When the pod already exists in the pods map (the if block of this conditional, which is outside this diff), we only update its LastAccessTime but do not update its Medium. If a prefix is migrated to a different storage tier, the tier information will remain stale. Please ensure that podInfo.Medium = medium is also updated when the pod already exists.

} else {
pods[podName] = &PodInfo{
SourcePod: podName,
Medium: medium,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Similarly, when the pod already exists in the pods map (the if block of this conditional, which is outside this diff), we should also update podInfo.Medium = medium to prevent stale tier information when a prefix is migrated to a different storage tier.

@wuyan-zs
wuyan-zs force-pushed the fix/2285-kv-event-decoder-map-format branch from 39be9ed to 059fe5e Compare August 20, 2026 05:00
@googs1025
googs1025 requested a lite review from Copilot August 20, 2026 05:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR restores compatibility with current vLLM KV-event msgpack wire formats (including the post-#42892 map encoding) and improves prefix-routing correctness by carrying tier/adaptor/group metadata end-to-end and weighting prefix match scores by storage tier.

Changes:

  • Extend KV-event decoding to accept map-encoded single events, legacy tag-first arrays, and [ts, events] batch wrappers; decode additional vLLM fields (medium, lora_name, group_idx, extra_keys, etc.).
  • Add tier-aware prefix scoring by recording medium per (prefix, pod) and weighting MatchPrefix scores (GPU/CPU/STORAGE).
  • Plumb medium/lora_name/group_idx through the kvevent handler and store-provider adapter into the sync prefix indexer; add focused compatibility and scoring tests.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pkg/utils/syncprefixcacheindexer/sync_hash.go Record per-pod storage tier for prefixes and weight MatchPrefix scores by tier; add APIs to add prefixes with medium.
pkg/utils/syncprefixcacheindexer/medium_scoring_test.go New tests validating tier weighting via both direct prefix insertion and event ingestion.
pkg/utils/syncprefixcacheindexer/events.go Extend sync event structs to carry medium, lora_name, and group_idx.
pkg/kvevent/interfaces.go Extend kvevent interfaces to include tier/adaptor/group metadata.
pkg/kvevent/handler.go Populate and forward medium/lora_name/group_idx from decoded KV events into sync events.
pkg/cache/store_providers.go Plumb new metadata through the sync indexer adapter.
pkg/cache/kvcache/msgpack_decoder.go Decode vLLM map-format events and legacy single-event arrays; broaden decoding to all supported shapes and fields.
pkg/cache/kvcache/event_types.go Extend KV event types with additional optional fields retained during decode.
pkg/cache/kvcache/decoder_vllm_compat_test.go New byte-exact fixtures ensuring decoder compatibility with real vLLM payload shapes.
Suppressed comments (1)

pkg/utils/syncprefixcacheindexer/sync_hash.go:583

  • addPrefixToPodLocked has the same issue as AddPrefixWithMedium: when the pod entry already exists, it doesn't update PodInfo.Medium. That can leave tier info stale across repeated BlockStored events and skew MatchPrefix weighting.
	if podInfo, exists := pods[podName]; exists {
		podInfo.LastAccessTime.Store(now)
	} else {
		pods[podName] = &PodInfo{
			SourcePod: podName,

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +438 to +459
func toExtraKeys(v any) ([][]interface{}, error) {
if v == nil {
return nil, nil
}
raw, ok := v.([]interface{})
if !ok {
return nil, fmt.Errorf("expected []interface{}, got %T", v)
}
out := make([][]interface{}, len(raw))
for i, x := range raw {
if x == nil {
out[i] = nil
continue
}
entry, ok := x.([]interface{})
if !ok {
return nil, fmt.Errorf("extra_keys[%d]: expected []interface{}, got %T", i, x)
}
out[i] = entry
}
return out, nil
}
Comment on lines 118 to 122
// 0: batch timestamp
tsFloat, ok := rawBatch[0].(float64)
tsFloat, ok := arr[0].(float64)
if !ok {
return nil, fmt.Errorf("invalid batch timestamp type: %T", rawBatch[0])
return nil, fmt.Errorf("invalid batch timestamp type: %T", arr[0])
}
Comment on lines 438 to 442
if podInfo, exists := pods[podName]; exists {
podInfo.LastAccessTime.Store(now)
} else {
pods[podName] = &PodInfo{
SourcePod: podName,
@varungup90

Copy link
Copy Markdown
Collaborator

Thanks for the PR. The tier-aware scoring and format compatibility improvements look solid, but there are a few edge cases involving state updates and logic duplication that need addressing:

  • sync_hash.go:438 (Stale Medium State): PodInfo.Medium is never updated when a repeat BlockStored event arrives for an existing (prefix, pod) entry; only LastAccessTime refreshes. If a block moves tiers (e.g., GPU → CPU offload), the pod stays permanently scored at its original medium, undermining the tier-aware scoring logic.
  • sync_hash.go:340 (Overly Broad Removals): ProcessBlockRemoved ignores event.Medium and event.GroupIdx and deletes the entire prefix-map entry unconditionally. Since the BlockRemovedEvent doc comment claims these fields scope removals to a specific tier/group, a tier-scoped removal (like an eviction from GPU that leaves the block on CPU) will incorrectly erase all knowledge of that pod's cached prefix.
  • msgpack_decoder.go:226 (Duplicated Parsing Logic): parseEventArray and parseEventMap duplicate roughly 30 lines of optional-field parsing logic. Consolidating this into a shared helper function will prevent silent format-specific decoding gaps if one path is updated in the future but the other is missed.

@varungup90

Copy link
Copy Markdown
Collaborator

1. Stale Medium state on existing (prefix, pod) entries (sync_hash.go ~438 and ~579)

Currently, AddPrefixWithMedium and addPrefixToPodLocked write Medium only when creating a new PodInfo. If the pod is already in the map, they only refresh LastAccessTime.

This breaks the feature this PR is adding. ZMQ is at-least-once, and vLLM CPU offload can emit another BlockStored for the same block (sometimes after BlockRemoved). The first event may be GPU or empty; a later one may be CPU/STORAGE. MatchPrefix keeps scoring at 1.0, which is the over-credit this PR aims to fix.

Please update the medium on the existing path, ensuring an empty value doesn't overwrite a known tier:

if podInfo, exists := pods[podName]; exists {
    podInfo.LastAccessTime.Store(now)
    if medium != "" {
        podInfo.Medium = medium
    }
}

Suggestion: Add a test that stores the same (prefix, pod) as GPU then CPU, and asserts the score drops from 100 to 50.

2. "Current vLLM" fixtures do not match the real ZMQ shape (decoder_vllm_compat_test.go)

The live ZmqEventPublisher encodes an EventBatch, which is still array_like: [ts, [event, ...], dp_rank?]. PR #42892 only changed the inner KVCacheEvent structs to maps. The production payload is an outer array with map events inside.

The new tests cover:

  • A top-level single map (vllmMap*)
  • An outer batch with array inner events (legacyArray*)

They never cover [ts, [{type: BlockStored, ...}]]. On that path, the old decoder fails with expected msgpack array, got map[...], not the invalid code=0x88 decoding array length cited in the PR description (which happens when unmarshaling a map into a slice). While decodeBatchArray looks like it can parse inner maps, the production shape lacks a regression test.

Suggestion: Add a fixture encoded as a real KVEventBatch (msgspec.msgpack.Encoder().encode(...)), including an optional data_parallel_rank third element, rather than a bare BlockStored.

…ring

- DecodeEventBatch now accepts the msgspec map encoding vLLM switched to in
  vllm-project/vllm#42892 (single event per ZMQ message, flat map with a
  `type` key), the legacy tag-first array encoding, and the [ts, events]
  batch wrapper. Previously every map payload failed to unmarshal, so
  kv-events prefix routing was broken against current vLLM.
- BlockStored/BlockRemoved now parse all fields: lora_id, medium, lora_name,
  extra_keys, group_idx, kv_cache_spec_kind, kv_cache_spec_sliding_window,
  locality (map and array forms).
- syncprefixcacheindexer records the storage medium per (prefix, pod) and
  weights MatchPrefix scores (GPU=1.0, CPU=0.5, STORAGE=0.25; unspecified
  keeps full score) so pods holding a prefix only on a slower tier are not
  over-credited.
- Plumb medium/lora_name/group_idx through the kvevent handler and the
  store provider adapter (previously dropped at the handler).

Fixes vllm-project#2285

Signed-off-by: wuyan-zs <91841343+wuyan-zs@users.noreply.github.com>
@henrichter

Copy link
Copy Markdown
Contributor

Bumping this. Running into the same issue

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.

KV event decoder drops group_idx/medium/lora_name, breaking prefix matching for hybrid and multi-tier models

4 participants