Skip to content

Commit 2121d7a

Browse files
kalantarahg-g
andauthored
feat(epp): add soft-reflective-ceiling usage limit policy (#1956)
* feat(epp): add soft-reflective-ceiling usage limit policy The static usage limit policy applies one ceiling uniformly across priorities. This policy graduates the ceiling by band rank so the highest-priority band is never gated and lower bands are gated first as pool saturation rises, reserving headroom for critical traffic without hard-classifying any priority as reservable. Signed-off-by: Michael Kalantar <kalantar@us.ibm.com> * docs(epp): clarify when to use soft-reflective-ceiling policy Refocus the "Why choose" section on continuous rate control, saturation-adaptive ceilings, and lack of parameters. These are the properties that distinguish this policy from priority-holdback-policy, rather than the priority-awareness both plugins share. Signed-off-by: Michael Kalantar <kalantar@us.ibm.com> * refactor(epp): key soft-reflective-ceiling counters by priority The flow-control registry provisions and retires priority bands at runtime, so a rank-indexed counter follows the rank rather than the band when the priority set changes. Keying counters by priority value via sync.Map preserves each band's alternation state when new priorities appear between existing ones. Signed-off-by: Michael Kalantar <kalantar@us.ibm.com> * fix(epp): share tick across gated bands in soft-reflective-ceiling Per-band counters desynchronize under rising saturation and, combined with the dispatch loop's short-circuit at the first gated band, starve lower gated bands. Use a single shared atomic tick so all gated bands open together on the same call. Signed-off-by: Michael Kalantar <kalantar@us.ibm.com> * docs(epp): permit bounded dispatch-spreading state in UsageLimitPolicy Soften the interface docstring from MUST be stateless to SHOULD be stateless and explicitly permit small bounded dispatch-spreading state (e.g. tick counters). Signal conditioning stays out of usage-limit policies. Update the soft-reflective-ceiling README's Not-Stateless bullet to match. Signed-off-by: Michael Kalantar <kalantar@us.ibm.com> --------- Signed-off-by: Michael Kalantar <kalantar@us.ibm.com> Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com>
1 parent b9eb0a7 commit 2121d7a

6 files changed

Lines changed: 518 additions & 4 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import (
8585
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization"
8686
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits"
8787
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits/priorityholdback"
88+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/flowcontrol/usagelimits/softreflectiveceiling"
8889
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/admitter/latencyslo"
8990
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/admitter/probabilisticadmitter"
9091
reqdataprodprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/approximateprefix"
@@ -610,6 +611,7 @@ func (r *Runner) registerInTreePlugins() {
610611
fwkplugin.Register(slodeadline.SLODeadlineOrderingPolicyType, slodeadline.SLODeadlineOrderingPolicyFactory)
611612
fwkplugin.Register(usagelimits.StaticUsageLimitPolicyType, usagelimits.StaticPolicyFactory)
612613
fwkplugin.Register(priorityholdback.PolicyType, priorityholdback.PolicyFactory)
614+
fwkplugin.Register(softreflectiveceiling.PolicyType, softreflectiveceiling.Factory)
613615

614616
// Register Request level data producer plugins as defaults for their respective data keys.
615617
fwkplugin.RegisterAsDefaultProducer(reqdataprodprefix.ApproxPrefixCachePluginType, reqdataprodprefix.ApproxPrefixCacheFactory, attrprefix.PrefixCacheMatchInfoDataKey)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# EPP configuration for the soft-reflective-ceiling usage limit policy.
2+
#
3+
# The policy applies a graduated, priority-aware dispatch ceiling: the
4+
# highest-priority band is never gated, and progressively lower-priority
5+
# bands are gated first as pool saturation rises. Gated items remain in
6+
# their queues; the ceiling controls dispatch, not admission.
7+
apiVersion: llm-d.ai/v1alpha1
8+
kind: EndpointPickerConfig
9+
featureGates:
10+
- flowControl
11+
plugins:
12+
- type: queue-scorer
13+
- type: kv-cache-utilization-scorer
14+
- type: prefix-cache-scorer
15+
- type: soft-reflective-ceiling-policy
16+
schedulingProfiles:
17+
- name: default
18+
plugins:
19+
- pluginRef: queue-scorer
20+
weight: 2.0
21+
- pluginRef: kv-cache-utilization-scorer
22+
weight: 2.0
23+
- pluginRef: prefix-cache-scorer
24+
weight: 3.0
25+
flowControl:
26+
usageLimitPolicyPluginRef: soft-reflective-ceiling-policy

pkg/epp/framework/interface/flowcontrol/plugins.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,12 @@ type SaturationDetector interface {
134134
// Saturation represents resource usage as a fraction of total capacity (0.0 = idle, 1.0 = fully saturated)
135135
// as described in [/pkg/epp/flowcontrol/contracts.SaturationDetector]
136136
//
137-
// Architecture (Stateless Singleton):
137+
// Architecture (Mostly Stateless Singleton):
138138
// UsageLimitPolicy plugins are Singletons. A single instance handles limit computation for all priority bands.
139-
// The plugin MUST be stateless: it is a pure function that maps the current saturation and
140-
// active priority domain to a set of ceilings. Any signal conditioning (trend detection, smoothing) belongs in
141-
// the SaturationDetector layer, not here.
139+
// The plugin SHOULD be stateless -- a pure function mapping the current saturation and active priority
140+
// domain to a set of ceilings. Small bounded dispatch-spreading state (e.g. a tick counter used to fold
141+
// successive calls into a proportional duty cycle) is permitted; signal conditioning (trend detection,
142+
// smoothing) is not, and belongs in the SaturationDetector layer.
142143
//
143144
// Integration:
144145
// This policy is called during dispatch decision-making, before a request is allowed to proceed. For each
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Soft Reflective Ceiling Usage Limit Policy
2+
3+
**Type:** `soft-reflective-ceiling-policy`
4+
5+
A usage limit policy that applies a graduated, priority-aware dispatch ceiling. Where the [static usage limit policy](../README.md) applies one ceiling uniformly across all priorities, this policy tightens the ceiling for lower-priority bands as pool saturation rises, reserving headroom for higher-priority traffic.
6+
7+
The ceiling controls **dispatch**, not admission. Requests continue to be enqueued as they arrive; a gated band simply is not drawn from for dispatch on that call, so its items remain in their queues until the ceiling opens.
8+
9+
## Why choose this policy?
10+
11+
- **Continuous Rate Control**: Once a band's ceiling is reached, the policy alternates open and closed across calls so that the effective dispatch rate degrades continuously with saturation. This is unlike step-function gating (`static-usage-limit-policy`, `priority-holdback-policy`), which is either fully open or fully closed at a fixed threshold.
12+
- **Saturation-Adaptive Ceilings**: The per-band ceilings shift with the observed saturation itself. Under light load, lower bands see permissive ceilings; under heavy load, ceilings compress toward zero. Fixed-threshold policies (`priority-holdback-policy`, `static-usage-limit-policy`) hold their thresholds regardless of current load.
13+
- **No Policy Parameters**: Behavior is derived entirely from saturation and the active priority count. Suits deployments that prefer a single well-defined schedule over per-environment tuning.
14+
15+
Deployments that need to bound the lowest band's gating aggressiveness explicitly should use `priority-holdback-policy` instead.
16+
17+
## What it does
18+
19+
For each call, the policy receives the current pool `saturation` (from the configured `SaturationDetector`) and an ordered list of `priorities` where `priorities[0]` is the highest. It returns one ceiling per band. Each ceiling is compared against saturation by the Flow Controller: when saturation exceeds the ceiling, the band is gated for that call and its items are held in queue.
20+
21+
**Reflective ceiling per band:**
22+
23+
ceiling[i] = 1 - i * saturation / (N - 1)
24+
25+
where `N = len(priorities)`. Band 0 receives `ceiling[0] = 1.0` and dispatches while saturation is below 1.0. At `saturation >= 1.0` the Flow Controller halts every band, including band 0, because it gates on `saturation >= ceiling`.
26+
27+
**Per-band decision:**
28+
29+
- If `saturation < ceiling[i]`: the band is fully open (`1.0`); items dispatch normally.
30+
- If `saturation >= 1.0`: non-critical bands are fully gated (`0.0`); their items remain queued until saturation drops.
31+
- Otherwise the band is at or past its reflective ceiling. It alternates open (`1.0`) and closed (`0.0`) across calls with
32+
33+
period = round(saturation / (1 - saturation))
34+
35+
so that, on average, each gated band's ceiling is open on 1 out of every `period` calls. Higher saturation lengthens the gated intervals; the effective dispatch rate approximates `(1 - saturation) / saturation`.
36+
37+
All gated bands share a single policy-wide tick, so on any given call either every gated band's ceiling is open or every gated band's ceiling is closed. That monotonicity across ranks is required because the Flow Controller's dispatch loop aborts at the first band whose ceiling gates. The shared tick is bounded state used only to spread dispatch evenly across calls; signal conditioning (smoothing, hysteresis, trend detection) is delegated to the Saturation Detector layer per the `UsageLimitPolicy` contract.
38+
39+
## Inputs consumed
40+
41+
This policy consumes runtime signals passed by the Flow Controller:
42+
43+
- **Pool Saturation**: The current saturation value from the configured `SaturationDetector` plugin.
44+
- **Priority Bands**: The ordered list of active priorities. Only the number of bands and their rank order matter, not the numeric priority values.
45+
46+
## Configuration
47+
48+
This policy takes no parameters. Any non-empty parameters block is rejected at load time.
49+
50+
```yaml
51+
plugins:
52+
- type: soft-reflective-ceiling-policy
53+
flowControl:
54+
usageLimitPolicyPluginRef: soft-reflective-ceiling-policy
55+
```
56+
57+
Unlike the static usage limit policy, this policy is **not** framework-injected. Declare it explicitly to activate it.
58+
59+
## Trade-offs
60+
61+
- **Not Stateless**: The alternation pattern is only observable across calls, so the policy keeps one policy-wide atomic tick counter. This is small bounded state that the `UsageLimitPolicy` contract permits for dispatch spreading, but it means the policy is not a pure function of its inputs: two calls with the same `(saturation, priorities)` will not always return the same result.
62+
- **Coarse Rate Control**: The effective dispatch rate is `1 / period` for integer `period`, so the gated dispatch rate transitions in discrete steps rather than smoothly with saturation.
63+
- **Rank-Only, Not Magnitude**: The ceiling depends only on the rank of each priority, not the numeric spacing between them. Two adjacent bands with a wide gap in priority values are treated the same as two bands with a small gap.
64+
- **Requires Multiple Bands**: With a single band the policy degenerates to always-open. Differentiation begins at two or more bands.
65+
- **Queue Growth Under Sustained Saturation**: Because gated items remain queued rather than being rejected, lower-priority queues can grow while saturation is high. Bounding queue size is the responsibility of the eviction plugins, not this policy.
66+
67+
## Related Documentation
68+
- [Static Usage Limit Policy](../README.md)
69+
- [Flow Control User Guide](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/v1.5.0/site-src/guides/flow-control.md)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package softreflectiveceiling implements a UsageLimitPolicy that gates
18+
// lower-priority bands proportionally as saturation rises. For each band i
19+
// (priorities ordered highest first) it computes a reflective ceiling
20+
//
21+
// ceiling[i] = 1 - i*saturation/(N-1)
22+
//
23+
// When saturation reaches a band's ceiling the policy alternates ceiling=1.0
24+
// and ceiling=0.0 across calls so that, on average, each gated band's
25+
// ceiling is open on 1/period of calls, where
26+
// period = round(saturation/(1-saturation)).
27+
//
28+
// A single policy-wide tick counter drives the alternation. All gated bands
29+
// share it, so on any given call either every gated band's ceiling is open
30+
// or every gated band's ceiling is closed. This monotonicity is required by
31+
// the dispatch loop, which stops at the first band whose ceiling is at or
32+
// below current saturation. Per-band counters would drift out of phase as
33+
// bands enter gating at different saturations, so a lower gated band's
34+
// open ticks would repeatedly land on a higher gated band's closed ticks
35+
// and the lower band would starve.
36+
//
37+
// The single tick is small bounded state used only for dispatch spreading,
38+
// which the UsageLimitPolicy contract permits. Signal conditioning (trend
39+
// detection, smoothing) is not permitted here; it belongs in the
40+
// SaturationDetector layer.
41+
package softreflectiveceiling
42+
43+
import (
44+
"context"
45+
"encoding/json"
46+
"fmt"
47+
"math"
48+
"sync/atomic"
49+
50+
"github.com/go-logr/logr"
51+
"sigs.k8s.io/controller-runtime/pkg/log"
52+
53+
logutil "github.com/llm-d/llm-d-router/pkg/common/observability/logging"
54+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/flowcontrol"
55+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
56+
)
57+
58+
// PolicyType is the registration string. The YAML "type:" field in
59+
// pluginsCustomConfig must equal this value for the loader to find the
60+
// factory.
61+
const PolicyType = "soft-reflective-ceiling-policy"
62+
63+
// Factory creates a soft-reflective ceiling policy instance. The algorithm
64+
// has no tunable parameters, so any provided parameters block must be empty.
65+
// The framework's strict decoder (DisallowUnknownFields) surfaces config
66+
// typos at load time rather than silently ignoring them.
67+
func Factory(name string, rawConfig *json.Decoder, handle fwkplugin.Handle) (fwkplugin.Plugin, error) {
68+
if rawConfig != nil {
69+
var empty struct{}
70+
if err := rawConfig.Decode(&empty); err != nil {
71+
return nil, fmt.Errorf("soft-reflective-ceiling-policy takes no parameters: %w", err)
72+
}
73+
}
74+
logger := logr.Discard()
75+
if handle != nil {
76+
logger = log.FromContext(handle.Context())
77+
}
78+
return newPolicy(name, logger), nil
79+
}
80+
81+
type policy struct {
82+
name string
83+
84+
// tick advances once per ComputeLimit call in which at least one band is
85+
// in the alternating regime. All gated bands read the same value, so the
86+
// per-call gating decision is monotone across ranks -- required because
87+
// the dispatch loop stops at the first band whose ceiling has gated.
88+
tick atomic.Int64
89+
}
90+
91+
var _ flowcontrol.UsageLimitPolicy = (*policy)(nil)
92+
93+
func newPolicy(name string, logger logr.Logger) *policy {
94+
p := &policy{name: name}
95+
logger.WithName(p.TypedName().String()).V(logutil.DEFAULT).Info("Creating new SoftReflectiveCeilingPolicy")
96+
return p
97+
}
98+
99+
func (p *policy) TypedName() fwkplugin.TypedName {
100+
return fwkplugin.TypedName{Type: PolicyType, Name: p.name}
101+
}
102+
103+
// ComputeLimit returns per-band ceilings. priorities[0] is the highest
104+
// priority band and receives ceiling=1.0. Lower bands whose reflective
105+
// ceiling has been reached alternate between 1.0 and 0.0 with period
106+
// round(saturation/(1-saturation)), producing a proportional duty cycle.
107+
func (p *policy) ComputeLimit(_ context.Context, saturation float64, priorities []int) []float64 {
108+
n := len(priorities)
109+
ceilings := make([]float64, n)
110+
if n == 0 {
111+
return ceilings
112+
}
113+
if n == 1 {
114+
ceilings[0] = 1.0
115+
return ceilings
116+
}
117+
118+
// Ceilings decrease monotonically with rank, so any band is at or past
119+
// its reflective ceiling iff the lowest band is (whose ceiling is
120+
// 1 - saturation). Advance the shared tick only when at least one band
121+
// is in the alternating regime; below that saturation the policy is a
122+
// pure function of its inputs.
123+
inAlternatingRegime := saturation >= 1.0-saturation && saturation < 1.0
124+
125+
var open bool
126+
if inAlternatingRegime {
127+
// 1e-9 guards against round-off when saturation is very near 1.0.
128+
period := int64(math.Max(1, math.Round(saturation/(1.0-saturation+1e-9))))
129+
open = p.tick.Add(1)%period == 0
130+
}
131+
132+
for i := range priorities {
133+
if i == 0 {
134+
ceilings[i] = 1.0
135+
continue
136+
}
137+
138+
reflectiveCeiling := 1.0 - float64(i)*saturation/float64(n-1)
139+
140+
switch {
141+
case saturation < reflectiveCeiling:
142+
ceilings[i] = 1.0
143+
case saturation >= 1.0:
144+
ceilings[i] = 0.0
145+
default:
146+
if open {
147+
ceilings[i] = 1.0
148+
} else {
149+
ceilings[i] = 0.0
150+
}
151+
}
152+
}
153+
154+
return ceilings
155+
}

0 commit comments

Comments
 (0)