mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #4941] [RFC]: Range reads in Mooncake Store Get — transfer only the shared span of a KV object #1256
Labels
No labels
ascend
auto-closed
bug
bug
CI/Build
Common
documentation
enhancement
feature request
good first issue
help wanted
high priority
MetaX
nightly-failure
pull-request
PyTorch Backend
question
RFC
Roadmap
stale
Store
Tests
Transfer Engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/Mooncake#1256
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @huangxiaobai-ydmy on GitHub (Oct 26, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4941
Originally assigned to: @Aionw on GitHub.
Summary
This RFC proposes optional byte-range parameters for Mooncake Store's client-side
Get/BatchGetread path, so a reader can transfer only the span of a pooled KV object it actually shares, instead of the whole object. No master-side wire or metadata change is required for v1; objects stay format-opaque; lease semantics are unchanged.Baseline for triage
mainat7d0ff6c65c34(read path audited:GetReplicaList→allocateSlices/ transfer submit; no master wire change in v1).Get/BatchGetalways transfer[0, object_size). Proposed: optional(offset, length)ranges; omit ranges → same as today (backward compatible). Concrete waste case: 10-block object (1280 tokens), shared prefix 5 blocks (640) → transfer 1280 today, 640 with ranges.Motivation
Getis whole-object today: the read path resolvesGetReplicaList(key, tenant_id)and reads from byte 0 toobject_size(allocateSlicesinclient_buffer.cppcovers the full buffer for memory replicas and the full file for disk replicas).For prefix-cache consumers this is wasteful whenever the hit is partial. Concrete example: a request pools its KV as a 10-block object (1280 tokens, block size 128); the next request shares the first 5 blocks. The radix layer reports a 640-token hit, but the reader still transfers all 1280 tokens — the extra 50% of RDMA bandwidth, staging buffer, and host-to-device copy is pure waste. The longer the pooled prefix and the shorter the shared span, the worse the ratio. This is the same retrieval-span mismatch we worked through in llm-d/llm-d-router#2899.
Current integrations work around it by choosing fine-grained objects (vLLM keys KV in per-block batches, SGLang HiCache offloads per radix node). That avoids partial-read waste but pays a different cost: object-count amplification in master metadata, many small RPCs, and staging pressure on batched loads (vllm-project/vllm#63465 shows a split load over fine-grained keys exhausting the staging budget). Today granularity is a trade-off: coarse objects waste bandwidth on partial hits; fine objects bloat metadata and RPC counts. A range read dissolves the trade-off — clients keep coarse objects (fewer keys, fewer RPCs, larger sequential transfers) and still pay only for the shared span.
#380 (
BatchGet) batches independent objects and does not address ranges within one object; the two are orthogonal.Existing foundation (why this is cheap now)
The design is already range-friendly at every layer except the API:
ObjectPart(object_part.h) is "one byte range of an object's content, written independently; an object's content is an ordered sequence of parts; each part carries its own length and checksum". Every object has one part today, so v1 needs no multi-part resolution — and the abstraction a range read composes with already exists.buffer_address_ + size_(AllocatedBuffer::Descriptor,allocator.h). The transport can address any sub-range of that buffer as-is — no master-side wire change for v1.PutEnd; the read lease binds the whole object and stays unchanged. A ranged read adds no new consistency surface relative to a full read.allocateSlices()plus the transfer submit loop are the only places that apply an(offset, length)— offset arithmetic, not structural change.Proposed changes
Get(key, ranges)/BatchGet(keys, ranges_per_key); each range is(offset, length)in bytes within the object. The store stays format-opaque; converting a radix/token span to byte ranges is the client's job (it owns the layout).sum(range)/object_size(this ratio is the observed bandwidth saving), fallback-to-full-read count.Non-goals for v1
parts_container is the future evolution path; ranges and multi-part compose naturally, but v1 needs neither).Relationship to existing work
BatchPut/BatchGet) batches independent objects. Ranges here are within a single object; the two are orthogonal and compose naturally.Alternatives considered
GetRangeRPC: keeps the existing signature untouched but forks the read path; extendingGetwith an optional argument keeps one code path.Test plan
Open questions
Get/BatchGetwith an optional argument, or a separateGetRangeRPC?Context
Motivated by the retrieval-span work in llm-d/llm-d-router#2899 and by the split-load staging behavior in vllm-project/vllm#63465. I have a concrete implementation sketch in mind (optional ranges in the client API, applied at
allocateSlices()and the transfer submit loop, master-side wire untouched for v1) and would be glad to send a PR with unit tests and benchmark numbers once the API shape is agreed.@github-actions[bot] commented on GitHub (Oct 26, 2026):
Thanks for opening this issue, @huangxiaobai-ydmy!
207993101A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@thrower01 commented on GitHub (Oct 27, 2026):
Thanks for the clear write-up. A few quick points I want to confirm before I dig deeper:
Checksum on partial reads — You mention per-part checksum verification, but with a single part today (the whole object), how does this work for a range read? Do we recompute checksum over the covered sub-range, or is it a full-part checksum that only validates if the entire part was read?
Staging buffer savings for memory replicas — When we say "transfer only the sub-range", does that mean RDMA reads directly from
allocator_buffer_base + offset(no extra staging allocation), or does it still stage into a smaller intermediate buffer? The former is where the real bandwidth + staging win lives, just want to make sure that's the intended path.Excited to see this land.
@huangxiaobai-ydmy commented on GitHub (Oct 28, 2026):
Thanks for the questions — clarifying both:
1. Checksum on partial reads
Today every object is a single
ObjectPart, and the stored checksum is over that entire part. A ranged Get that only transfers a sub-span cannot recompute or match that full-part checksum from the bytes it received.So for v1 the rule is: verify a part's checksum only when the requested ranges fully cover that part. With one part today, that means:
[0, object_size)→ same full-object checksum path as todayWhen multi-part objects land later, the same rule composes: verify each fully covered part, skip partially covered ones. Happy to call this out more explicitly in the RFC text if useful.
2. Staging for memory replicas
Intended path is the former: RDMA (or the local memcpy path) addresses
buffer_address_ + offsetwithlengthequal to the range — i.e.allocateSlices()/ transfer submit emit sub-range tasks against the replica descriptor as-is, without allocating a full-object staging buffer and without an intermediate “copy whole object then slice” step.A smaller destination buffer sized to
sum(ranges)(or the caller's provided buffer) is fine; what we want to avoid is staging/Copy*proportional toobject_sizewhen only a prefix/span is needed. Disk/DFS replica paths get the same offset arithmetic into their read windows.Happy to dig into either point further if anything is still ambiguous.