[KV Offloading] Per-request tier filtering with TierFilter/TierMatcher - #48123
Conversation
|
|
||
| # TODO: decide how to handle unknown lookup_scope values — | ||
| # currently falls through to "all" silently. Options: log a | ||
| # warning (but this is per-block hot path), reject the request | ||
| # upstream, or keep silent for forward-compat with newer clients. |
There was a problem hiding this comment.
Open question: how should unknown lookup_scope values be handled? Currently falls through to "all" silently.
Options:
(1) log a warning — but this is per-block hot path, would spam on typos;
(2) reject the request upstream at the API/connector layer;
(3) keep silent for forward-compat with newer clients sending values an older server doesn't know.
| # TODO: consider a server-level config for the default | ||
| # lookup_scope. "primary" default would keep store cascade | ||
| # (writes to all tiers) but skip promotion on reads unless a | ||
| # request explicitly opts in with lookup_scope="all". |
There was a problem hiding this comment.
Open question: should we support a server-level config to change the default lookup_scope from "all" to "primary"? Use case: keep store cascade (writes to all tiers for durability) but avoid promotion on reads unless a request explicitly opts in with lookup_scope="all".
| @@ -27,6 +28,17 @@ | |||
| ) | |||
| from vllm.v1.kv_offload.base import OffloadingSpec | |||
|
|
|||
| LOOKUP_SCOPE_KEY = "lookup_scope" | |||
There was a problem hiding this comment.
How about this instead?
@dataclass
class ReqContext:
req_id: str
kv_transfer_params: dict[str, Any] | None = None
kv_lookup_scope: LookupScope = LookupScope.ALL
There was a problem hiding this comment.
Wouldn't we still need to parse "lookup_scope" from kv_transfer_params regardless?
IIUC this would require _create_req_context in the offloading connector scheduler to extract and set the field:
def _create_req_context(req: Request) -> ReqContext:
return ReqContext(
req_id=req.request_id,
kv_transfer_params=req.kv_transfer_params,
kv_lookup_scope=LookupScope(req.kv_transfer_params.get("lookup_scope", "all")),
)
This makes the offloading connector aware of a tiering-specific concept.
Am I missing something?
| class LookupScope(Enum): | ||
| """Controls which tiers are queried during KV cache lookup.""" | ||
|
|
||
| PRIMARY = "primary" |
There was a problem hiding this comment.
This is problematic since "primary" is a relative scope.
I think it should be something concrete (e.g. GPU/CPU etc.).
|
@ronensc Did some thinking with Claude and came up with a longer-term design. Per-request KV cache tier filteringOverviewA per-request mechanism to control which offloading tiers participate in load (lookup + promotion) and store (offload + cascade) paths. API (
|
| Key | Default | Controls |
|---|---|---|
kv_load_tiers |
all tiers | lookup + promotion |
kv_store_tiers |
all tiers | offload + cascade |
Each is a list of tier matchers. A tier participates if it matches any entry (OR). Each entry is an AND of its specified fields; omitted fields are unconstrained.
{
"kv_load_tiers": [
{"medium": "cpu", "locality": "global"},
{"medium": "storage", "locality": "local"}
]
}Filter axes
Medium — storage type:
| Value | Examples |
|---|---|
cpu |
Host DRAM (primary tier, P2P) |
storage |
Persistent (local FS, object store) |
Future:
accelerator— handled above the offloading manager; the tiering layer never sees it.
Locality — placement relative to the vLLM instance:
| Value | Meaning |
|---|---|
local |
Co-located with this instance (no IPC/network) |
global |
Anywhere |
Future values slot in between:
host(same machine),rack,dc,region. A tier declares one value; the request lists accepted values.
Entry syntax
Each entry is {field: value, ...}. Omitted fields are unconstrained (match anything). Multiple entries are OR'd — a tier matches if it satisfies any single entry fully.
[{"medium": "cpu"}, {"medium": "storage", "locality": "local"}]Means: any CPU tier, OR a storage tier that is local.
Examples
| Intent | Param value |
|---|---|
| Primary tier only | [{"medium": "cpu", "locality": "local"}] |
| CPU tiers everywhere | [{"medium": "cpu"}] |
| Everything local | [{"locality": "local"}] |
| Remote CPU + local storage | [{"medium": "cpu", "locality": "global"}, {"medium": "storage", "locality": "local"}] |
| GDS store bypass CPU | kv_store_tiers: [{"medium": "storage"}] |
| Everything (default) | omit key entirely |
Implementation
Parse once in _create_req_context (offloading connector). This consolidates all kv_transfer_params parsing — including existing P2P params (prefill, decode) which today are re-parsed on every P2P tier call.
@dataclass
class ReqContext:
req_id: str
kv_transfer_params: dict[str, Any] | None = None
load_tier_filter: TierFilter = TierFilter.ALL
store_tier_filter: TierFilter = TierFilter.ALL
prefill_params: dict[str, Any] | None = None
decode_params: dict[str, Any] | None = NoneTierFilter wraps the parsed list of matchers:
class TierMatcher(NamedTuple):
medium: Medium | None = None # None = unconstrained
locality: Locality | None = None
@dataclass(frozen=True)
class TierFilter:
matchers: tuple[TierMatcher, ...] = () # empty = match nothing
ALL: ClassVar["TierFilter"] # matches everything
def allows(self, medium: Medium, locality: Locality) -> bool:
return any(
(m.medium is None or m.medium == medium)
and (m.locality is None or m.locality == locality)
for m in self.matchers
)
TierFilter.ALL = TierFilter(matchers=(TierMatcher(),)) # single unconstrained entryEnforce at the tier (not the tiering manager). Each SecondaryTierManager checks against the filter:
class FileSystemTierManager(SecondaryTierManager):
medium = Medium.STORAGE
locality = Locality.LOCAL # set from config
def lookup(self, key, req_context):
if not req_context.load_tier_filter.allows(self.medium, self.locality):
return LookupResult.MISS
...The tiering manager stays unaware of filtering semantics.
|
@orozery Thanks for the detailed design, this is a good direction. Two thoughts: Parsing location — Enforcement location — I'd prefer centralizing the filter check in the tiering manager's loop rather than in each tier. The manager already decides who to call (e.g. |
My thinking that the existing kv_transfer_params fields used by the p2p tier, as well as the new proposed fields here should be "officially supported" by the offloading connector.
The problem is that you can have complex tiers that don't have a static medium/locality. |
|
This pull request has merge conflicts that must be resolved before it can be |
2c66fba to
df590d8
Compare
| event_medium: ClassVar[str] = MEDIUM_OBJ | ||
| filter_medium: ClassVar[Medium | None] = Medium.STORAGE |
There was a problem hiding this comment.
| event_medium: ClassVar[str] = MEDIUM_OBJ | |
| filter_medium: ClassVar[Medium | None] = Medium.STORAGE | |
| medium: ClassVar[Medium | None] = Medium.STORAGE |
Let's reconcile to a single classvar, and switch OffloadingEvent to use the Medium enum instead of str.
Then, in OffloadingEventsTracker (events.py) let's create a util function to convert Medium to str (e.g. MEDIUM_OBJ).
Same change on the fs tier.
| if self.filter_medium is not None and not req_context.load_tier_filter.allows( | ||
| self.filter_medium | ||
| ): | ||
| return LookupResult.MISS |
There was a problem hiding this comment.
Re-thinking this, since we already introduce Medium as a first-class citizen in kv_offload/base.py, I think your previous approach actually fits.
i.e., add a Medium | None classvar on SecondaryTierManager, and applying the filter on the tiering manager.
|
This pull request has merge conflicts that must be resolved before it can be |
df590d8 to
9ce038f
Compare
| @@ -43,9 +43,6 @@ class KVCacheEvent( | |||
|
|
|||
|
|
|||
| MEDIUM_GPU = "GPU" | |||
There was a problem hiding this comment.
I removed MEDIUM_CPU/MEDIUM_FS/MEDIUM_OBJ (now covered by Medium enum) but kept MEDIUM_GPU — it's used in block_pool.py and I wasn't sure if it's OK for v1/core/block_pool.py to import from v1/kv_offload/base.py.
There was a problem hiding this comment.
Actually, we don't want to touch this file.
It defines an API which is distinct from the one in kv_offload.
Instead, in the offloading connector we want translate kv_offload Medium to one of the strs defined here in kv_events.py.
There was a problem hiding this comment.
Makes sense. Done in 886bb89
Note that since we consolidated filter_medium and event_medium into a single enum, we no longer have Medium.STORAGE as articulated in the original design (instead we have FS and OBJ).
There was a problem hiding this comment.
@Change72 Didn't we agree that medium will be CPU/STORAGE (and not FS/OBJ)?
There was a problem hiding this comment.
Yes — that was my understanding as well. We agreed that Medium should remain coarse (CPU / STORAGE) at the end of #48281. FS and OBJ events should report as STORAGE.
There was a problem hiding this comment.
Thanks @orozery @Change72. I've updated Medium to CPU/STORAGE as agreed in #48281. Both FS and OBJ tiers now declare medium = Medium.STORAGE.
Two questions:
MEDIUM_FSandMEDIUM_OBJinkv_events.pyare now unused by the offloading path. Should we remove them?_MEDIUM_TO_EVENT_STRmapsMedium.CPU→MEDIUM_CPU(fromkv_events.py), butMedium.STORAGE→ bare"STORAGE"string (no constant exists). I hesitated to addMEDIUM_STORAGE = "STORAGE"tokv_events.pygiven the earlier guidance not to touch that file. Should we add it?
|
This pull request has merge conflicts that must be resolved before it can be |
9ce038f to
a5a9a5f
Compare
0a0d2d0 to
bc255c6
Compare
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
…ookup Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
This reverts commit 9f8712927dbfd1424e611174ee8b6653b89652f3. Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
Signed-off-by: Ronen Schaffer <ronen.schaffer@ibm.com>
589b26c to
67634b8
Compare
…-project#51646) Signed-off-by: Alex <jihui.huang@daocloud.io> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Or Ozeri <oro@il.ibm.com> Signed-off-by: Jyotirmoy Roy <jyotirmoyroy649@gmail.com>
Purpose
Adds per-request tier filtering to KV cache offloading.
Requests can specify which secondary tiers to load from via
kv_transfer_params["kv_load_tiers"], filtering by medium and locality.See detailed design discussion: #48123 (comment)
Key changes:
Mediumenum:CPU/STORAGE(coarse granularity, both FS and OBJ → STORAGE)TierMatcher(NamedTuple)with optionalmediumandlocalityfieldsTierFilterwithallows()— applied to secondary tiers only (primary always participates)_parse_tier_filter()validates request params with warning loggingMEDIUM_STORAGEwire constant inkv_events.py(replacesMEDIUM_FS/MEDIUM_OBJ)Localityenum onSecondaryTierManagerbase classDesign decisions (confirmed by reviewers):
Noneon tier side = "unconstrained" (passes any filter) — supports P2P tiers with dynamic localityNoneon matcher side = "match anything"Test Plan
Test Result
Essential Elements of an Effective PR Description Checklist
supported_models.mdandexamplesfor a new model.