Repository navigation
feat: Add selective KV transfer policy - #2833
Conversation
c75de27 to
1b64f41
Compare
|
Awesome work, will review asap |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved payload-handling and queue-gating correctness issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds an Alpha EPP plugin for selective per-request KV loading and offloading.
Changes:
- Implements preserve, disable, and threshold-based policies.
- Adds queue-pressure EWMA gating, tests, and documentation.
- Registers the plugin with the EPP.
File summaries
| File | Description |
|---|---|
pkg/epp/framework/plugins/requestcontrol/selectivekv/README.md |
Documents configuration and behavior. |
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go |
Implements selective KV policy logic. |
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin_test.go |
Tests configuration and policy behavior. |
cmd/epp/runner/runner.go |
Registers the Alpha plugin. |
Review details
Suppressed comments (6)
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go:289
- Once a high sample closes the gate, this code only updates
state.ewmawhen a strictly newerUpdateTimeis observed. During a metrics scrape outage, requests keep reusing the old timestamp and closed state, so external loading can remain disabled indefinitely instead of failing open. Apply a freshness bound tometrics.UpdateTimeand reset or skip the veto for stale samples, consistent with the existing metrics staleness handling.
if !found || sampleTime.Before(state.sampleTime) {
state = waitingQueueState{ewma: sample, sampleTime: sampleTime}
} else if sampleTime.After(state.sampleTime) {
elapsed := sampleTime.Sub(state.sampleTime)
weight := 1 - math.Exp(-math.Ln2*
elapsed.Seconds()/waitingQueueEWMAHalfLife.Seconds())
state.ewma += weight * (sample - state.ewma)
state.sampleTime = sampleTime
}
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go:334
- The documented fail-open behavior for invalid tier evidence is not implemented here: negative counts are accepted. For example, a negative GPU count increases
maxExternalBlocks-gpuBlocksand can enable loading for an invalid sample, while a negative external count can be converted into a valid zero. Reject any negative tier count, including the GPU and speculative entries, before calculating the token count and return(0, false).
gpuBlocks := byTier[gpuTierKey]
maxExternalBlocks := 0
for tier, blocks := range byTier {
if tier == gpuTierKey || tier == attrprefix.SpeculativeTierKey {
continue
}
maxExternalBlocks = max(maxExternalBlocks, blocks)
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go:225
- The closure can make no payload change when threshold loading is allowed and offloading is preserved, especially for requests without
kv_transfer_params, butMutatePayloadMapstill setsMutated=true.Director.repackagethen marshals every such request instead of forwarding the original body, adding avoidable per-request serialization and potentially changing the wire representation. Track whether a controlled field actually changed and mark the body mutated only for real edits.
request.Body.MutatePayloadMap(func(payload requesthandling.PayloadMap) {
params, _ := payload[commonrequest.FieldKVTransferParams].(map[string]any)
if params == nil {
if load != loadDisable && p.offloadPolicy != PolicyDisable {
return
}
params = map[string]any{}
payload[commonrequest.FieldKVTransferParams] = params
}
switch load {
case loadDisable:
params[fieldMaxLoadTokens] = json.Number("0")
case loadEnable:
delete(params, fieldMaxLoadTokens)
}
if p.offloadPolicy == PolicyDisable {
params[fieldMaxOffloadTokens] = json.Number("0")
}
})
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go:334
- The per-tier counts are summed across prompts before this function runs (
preciseprefixcache/producer.go:addPodMatch). Taking the max after that sum is not equivalent to summing each prompt's longest external tier:{gpu:10,cpu:20}plus{gpu:0,storage:20}becomes 10 external blocks here, although the request has 30 blocks outside GPU. Batched requests can therefore be forced to recompute despite meeting the threshold; publish an aggregate external count or retain per-prompt tier data.
for tier, blocks := range byTier {
if tier == gpuTierKey || tier == attrprefix.SpeculativeTierKey {
continue
}
maxExternalBlocks = max(maxExternalBlocks, blocks)
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go:245
- The decision ignores the client's
kv_load_tiers, even though vLLM uses that field to filter secondary tiers while CPU remains a direct source. A request that excludes storage can therefore pass this threshold from a large storage-only match and removemax_load_tokens, despite having too few permitted external tokens to justify loading. Evaluate the threshold against the tiers permitted by the request, or explicitly fail open when the filter cannot be interpreted.
tokens, ok := p.externalReusableTokens(endpoint)
if !ok {
logger.Info("no tier evidence; preserving KV load")
return loadPreserve
}
pkg/epp/framework/plugins/requestcontrol/selectivekv/plugin.go:250
- When
tokens < minExternalReusableTokens, this branch skipswaitingQueueVeto, so those requests never contribute samples to the endpoint EWMA. The first qualifying long-prefix request can therefore bypass the configured smoothing and hysteresis after pressure was observed only during shorter requests. Update the EWMA for every valid endpoint sample before combining it with the token-threshold decision.
action := loadEnable
if tokens < p.minExternalReusableTokens {
action = loadDisable
} else if p.waitingQueueVeto(endpoint) {
action = loadDisable
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if metadata == nil || metadata.ID.Name == "" || metrics == nil || | ||
| metrics.UpdateTime.IsZero() { | ||
| return false |
There was a problem hiding this comment.
This is a wider issue that will be addressed in another PR (See #2890)
|
This is very nice! What are the next steps? is this ready to review? |
|
Tested the latest EPP changes with:
Ready for review! @ahg-g |
ahg-g
left a comment
There was a problem hiding this comment.
Nice work, and the benchmark write-up is thorough. Build, vet and go test -race pass locally after merging main (92.8% coverage), and the earlier Copilot findings on RawMessage handling and per-endpoint state cleanup are addressed.
gpuTierKey = "gpu"is the third local copy of that tier name (kvevents.defaultEventSourceDeviceTier,kvcache/backend.go). Could it use a shared exported constant instead?- In the README, please state the minimum vLLM release containing #55885, and what happens on older engines (is
max_load_tokensignored, making the policy a silent no-op?). - Nit: the YAML in the description has
maxWaitingReques. Worth correcting so the tested config is unambiguous.
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
456421e to
f6a5f1d
Compare
|
thanks @ahg-g, I just addressed the comments 👍🏽 |
* feat: Initial selective KV plugin Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * feat: Add waiting queue EWMA Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * feat: Add waiting requests Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Convert OpenAI transfer parameters into a mutable map Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Add Endpoint lifecycle Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * chore: Pass linter Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Avoid repackaging unchanged selective KV requests Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * docs: Document selective KV multi-prompt limitation Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * chore: Document vLLM requirements Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * refactor: Share GPU tier name as a kvcache constant Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> --------- Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
* feat: Initial selective KV plugin Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * feat: Add waiting queue EWMA Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * feat: Add waiting requests Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Convert OpenAI transfer parameters into a mutable map Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Add Endpoint lifecycle Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * chore: Pass linter Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * fix: Avoid repackaging unchanged selective KV requests Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * docs: Document selective KV multi-prompt limitation Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * chore: Document vLLM requirements Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> * refactor: Share GPU tier name as a kvcache constant Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> --------- Signed-off-by: Alberto Perdomo <aperdomo@redhat.com> (cherry picked from commit 3923fd4)

What type of PR is this?
/kind feature
What this PR does / why we need it:
This PR adds the Alpha
selective-kv-policyrequest-control plugin. It lets the EPP control KV loading and offloading independently for each request.The load policy can preserve the client setting, disable external loading, or load only when the selected endpoint reports enough reusable KV outside GPU memory. Threshold mode can also veto loading when an endpoint-local EWMA of
vllm:num_requests_waitingreaches a configured limit. The offload policy can preserve offloading or setmax_offload_tokens: 0.Disabling a load sets
kv_transfer_params.max_load_tokens: 0. Allowing a load removes that field to restore the vLLM default. The plugin leaveskv_load_tiersunchanged. This requires vLLM PR #55885 (merged), or a build with the samemax_load_tokensAPI.The plugin is disabled unless EPP starts with
--allow-experimental-plugins. Unsupported request shapes fail open and remain unchanged. The coordinator prefill path is not supported because it replaceskv_transfer_paramsafter this hook runs.Performance evaluation
The experiment asks whether the proposed
1024 + 8per-request selective policy can outperform the existing behavior of loading every reusable external prefix while avoiding the cost of always recomputing reusable KV.Deployment
The deployment used llm-d Precise Prefix Caching with Qwen3-32B. It used the custom vLLM image (based on the aforementioned PR) to deploy 4 replicas of TP2 in one 8xH100 node.
Deployment profiles
multi-tier-offloading-nvmerhoai-distributed-default-selective-loading-disabledrhoai-distributed-default-selective-loading-combinedTiered offloading included a 256 GiB CPU tier and a local NVMe filesystem tier with 64 read threads and 64 write threads:
The EPP config for the selective loading looked like this:
The forced-load profile represents the behavior currently present in deployments without this feature. Forced recompute is a diagnostic control that establishes the other static extreme. The feature arm is the exact proposed configuration:
minExternalReusableTokens: 1024andmaxWaitingRequests: 8.Workloads
Each GuideLLM profile used a 2,048-request warmup followed by a 300-second concurrent phase. Requests contained a reusable prefix, 256 uncached prompt tokens, and 128 requested output tokens. The prefix corpus contained 2,048 entries, each run used one turn, and the random seed was fixed at
20260909.The five workload cells target regions where earlier crossover measurements showed different preferred static actions:
guidellm-selective-loading-calibration-p1024-c64guidellm-selective-loading-calibration-p2048-c64guidellm-selective-loading-calibration-p4096-c32guidellm-selective-loading-calibration-p8192-c32guidellm-selective-loading-calibration-p8192-c64This evaluation includes pressure-heavy workloads designed to stress external KV restoration: reusable prefixes up to 8,192 tokens at concurrency 64, using CPU and NVMe offloading. Under uncapped loading, restore-path contention caused p99 TTFT to reach 57.5 seconds at 8K/C64 and limited output throughput to 962.5 tokens/s. The proposed 1024 + 8 policy reduced p99 TTFT to 5.48 seconds and increased output throughput to 1,565.6 tokens/s. These results demonstrate the policy’s behavior when external KV loading becomes a bottleneck. The collected telemetry does not establish whether NVMe, CPU staging, CPU-to-GPU transfer, or their combined queueing was the individually saturated component.
Please, keep in mind that the figures added to this PR description come from another (larger) report, therefore the image indices are not correct.
Output-token throughput
Output-token throughput is the rate of generated tokens, excluding input tokens. Because every request requested 128 output tokens, it follows successful request throughput while remaining directly relevant to serving capacity.
The proposed policy exceeded the current forced-load baseline in four of five cells. Across the matrix, its geometric-mean output-token throughput was 20.6% higher than forced load and 12.6% higher than forced recompute. At 8K/C64, output throughput increased from 962.5 to 1,565.6 tokens/s, a 62.7% improvement over current behavior.
Figure 3: Output-token throughput from GuideLLM. The proposed
1024 + 8policy improves serving throughput over uncapped loading in four cells and avoids the forced-load collapse at 8K/C32 and 8K/C64.Time to first token
TTFT captures lookup, restore, recomputation, scheduling, and prefill delay before generation starts. It is the latency metric most directly affected by the load-versus-recompute decision.
Relative to current forced loading, the proposed policy reduced geometric-mean mean TTFT by 62.2% and p99 TTFT by 88.8%. At 8K/C64, mean TTFT fell from 2,584 ms to 632 ms and p99 fell from 57.5 seconds to 5.48 seconds. Forced recompute retained the best TTFT at some high-pressure points, but it completed fewer requests and produced fewer output tokens than the proposed policy.
Figure 5 and Figure 6: Mean and p99 TTFT from GuideLLM. Uncapped loading develops severe tails when the restore path is under pressure; selective loading avoids that collapse while retaining external reuse.
Inter-token latency
ITL measures spacing between streamed output tokens after the first token. It primarily reflects decode behavior and interference after prefill, so it exposes a different tradeoff from TTFT.
Forced loading had the lowest mean ITL in all cells, but it paid for that result with substantially worse TTFT and completed throughput under restore pressure. The proposed policy's geometric-mean ITL was 5.4% higher than forced load and 11.8% lower than forced recompute. Its p99 ITL was 10.4% lower than forced load and 8.5% lower than forced recompute. The policy therefore improves admission and prefill behavior without introducing a large decode-latency penalty.
Figure 7: Mean ITL from GuideLLM. The proposed policy remains between the two static controls, showing that the throughput and TTFT gains do not come from severely degraded steady-state generation.
Which issue(s) this PR fixes:
Fixes #TODO
Test plan:
go test ./pkg/epp/framework/plugins/requestcontrol/selectivekv.AI assistance from Codex was used to inspect the implementation, update tests and documentation.
Release note: