Skip to content

Commit 96391d1

Browse files
committed
epp: keep average aggregation, document credit assumptions
Revert the utilization detector's pool aggregation from max back to the average it had before this PR. The scrape-lag credit corrects each endpoint's score before aggregation, so the fix holds under average; max changes Saturation() semantics for other consumers and warrants its own design, tracked separately. Document the credit's assumptions in the README and Saturation doc comment: it requires runningRequestsSpec to be mapped, a single EPP replica (InFlightRequests is per-replica while waiting+running is engine-global), and aggregated (non-P/D) pools, since the default producer holds the prefill request counter until end-of-stream. Signed-off-by: Angelo Ruocco <ang@zurich.ibm.com>
1 parent d83106e commit 96391d1

4 files changed

Lines changed: 31 additions & 25 deletions

File tree

pkg/epp/flowcontrol/controller/internal/processor.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,6 @@ func (p *Processor) dispatchCycle(ctx context.Context) bool {
362362

363363
pool := p.endpointCandidates.Locate(ctx, nil)
364364
p.poolEmpty = len(pool) == 0
365-
366365
saturation := p.saturationDetector.Saturation(ctx, pool)
367366

368367
// Record pool saturation metric

pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization/README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ It relies on a "roofline model", evaluating both the queue depth and the KV cach
1818

1919
EndpointScore = max(QueueDepth / QueueThreshold, KVCacheUsage / KVCacheThreshold)
2020

21-
The global pool saturation is the roofline across all candidate endpoints:
21+
The global pool saturation is the average of the endpoint scores:
2222

23-
PoolSaturation = Max(EndpointScore)
23+
PoolSaturation = Average(EndpointScore)
2424

25-
The pool is saturated as soon as its hottest endpoint is, so a single overloaded endpoint is not diluted by idle ones.
2625
*Note: Endpoints with missing or stale metrics are aggressively scored as 100% saturated.*
2726

2827
**Scrape-lag compensation:** `WaitingQueueSize` is scraped on a poller while the Flow Controller's dispatch loop runs far faster, so between two scrapes the queue term is stale-low and the gate would let the controller over-dispatch. When an `inflight-load-producer` is configured, the queue term is corrected by the in-flight requests the scrape does not yet reflect:
@@ -32,6 +31,12 @@ The pool is saturated as soon as its hottest endpoint is, so a single overloaded
3231

3332
`InFlightRequests` is the producer's per-endpoint counter, incremented at dispatch and decremented on request completion, so the correction already accounts for requests that returned since the last scrape. Endpoints without the attribute contribute zero credit.
3433

34+
The correction rests on assumptions that hold for a single-writer, aggregated deployment:
35+
36+
- **`RunningRequestsSize` must be populated.** The subtraction of running requests is only sound when the metrics mapping defines `runningRequestsSpec` (defaults exist for vLLM and SGLang). A custom mapping without it leaves `RunningRequestsSize` at 0, and the credit over-counts by the running count.
37+
- **Single EPP replica.** `InFlightRequests` is tracked per EPP replica, while `WaitingQueueSize + RunningRequestsSize` is engine-global. With multiple EPP replicas the credit floors at 0 and the compensation silently disappears (the same single-writer assumption the concurrency detector makes).
38+
- **Aggregated (non-P/D) pools.** With the default producer config the prefill profile's request counter is held until end-of-stream, so under P/D disaggregation a prefill endpoint shows phantom credit for the full decode duration of every request whose prefill it served. Correcting this requires releasing the prefill request counter at start-of-stream in the producer.
39+
3540
### Role in Scheduling (The Traffic Shaper)
3641
The detector implements the `Filter` interface to protect individual endpoints. It removes endpoints from candidate lists if their telemetry is stale, or if they exceed specific safety limits:
3742

pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization/detector.go

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -134,16 +134,12 @@ func (d *Detector) TypedName() fwkplugin.TypedName {
134134
//
135135
// It returns an aggregate saturation signal where:
136136
//
137-
// Saturation = Max(PodSaturationScore) // hottest endpoint drives the pool
137+
// Saturation = Average(PodSaturationScore)
138138
//
139139
// For each pod, the score is determined by the most constrained resource (Compute or Memory):
140140
//
141141
// PodScore = Max(WaitingQueue / QueueThreshold, KVCacheUsage / KVCacheThreshold)
142142
//
143-
// Aggregation is the MAX across endpoints (roofline at the pool level): the pool
144-
// is saturated as soon as its hottest endpoint is, so a single overloaded
145-
// endpoint is not diluted by idle ones.
146-
//
147143
// # Scrape-lag compensation
148144
//
149145
// WaitingQueueSize is scraped on a poller (default 50ms) while the flow
@@ -157,21 +153,27 @@ func (d *Detector) TypedName() fwkplugin.TypedName {
157153
//
158154
// credit = max(0, InFlightRequests - (WaitingQueueSize + RunningRequestsSize))
159155
//
160-
// This credit is added only to the queue-depth term (the fast, controllable
161-
// signal); the KV-cache term is left as measured. Endpoints without the
162-
// attribute contribute zero credit and fall back to the scraped queue depth.
156+
// The credit corrects each endpoint's score before aggregation, so it holds
157+
// under either aggregation shape. It is added only to the queue-depth term (the
158+
// fast, controllable signal); the KV-cache term is left as measured. Endpoints
159+
// without the attribute contribute zero credit and fall back to the scraped
160+
// queue depth.
161+
//
162+
// The compensation assumes aggregated (non-P/D) pools. Under P/D disaggregation
163+
// with the default producer config, a prefill endpoint's in-flight request count
164+
// stays elevated for the whole decode duration of every request whose prefill it
165+
// served, so its credit is inflated; see the package README.
163166
func (d *Detector) Saturation(_ context.Context, candidates []datalayer.Endpoint) float64 {
164167
if len(candidates) == 0 {
165168
return 1.0
166169
}
167170

168-
var maxScore float64
171+
var totalScore float64
169172
for _, e := range candidates {
170173
metrics := e.GetMetrics()
171174

172175
if metrics == nil || time.Since(metrics.UpdateTime) > d.config.MetricsStalenessThreshold {
173-
// Stale/missing metrics are treated as fully saturated (conservative).
174-
maxScore = max(maxScore, 1.0)
176+
totalScore += 1.0
175177
continue
176178
}
177179

@@ -186,10 +188,10 @@ func (d *Detector) Saturation(_ context.Context, candidates []datalayer.Endpoint
186188
kvRatio := metrics.KVCacheUsagePercent / d.config.KVCacheUtilThreshold
187189

188190
// Roofline Analysis: The pod is saturated if either resource is exhausted.
189-
maxScore = max(maxScore, max(qRatio, kvRatio))
191+
totalScore += max(qRatio, kvRatio)
190192
}
191193

192-
return maxScore
194+
return totalScore / float64(len(candidates))
193195
}
194196

195197
// Filter blocks traffic to specific pods that are physically saturated or exceeding their safety limits.

pkg/epp/framework/plugins/flowcontrol/saturationdetector/utilization/detector_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ func TestDetector_Saturation(t *testing.T) {
217217
// Pod2: Q=0/5(0.0), KV=0.2/0.9(0.22). Max=0.22...
218218
makePodMetric("pod2", 0, 0.2, baseTime),
219219
},
220-
// Max(0.2, 0.222...) = 0.222... (pool = hottest endpoint)
221-
wantSaturation: 0.2 / 0.9,
220+
// Avg(0.2, 0.222...) = 0.2111...
221+
wantSaturation: (0.2 + (0.2 / 0.9)) / 2.0,
222222
},
223223
{
224224
name: "Multiple pods, one good, one stale",
@@ -228,8 +228,8 @@ func TestDetector_Saturation(t *testing.T) {
228228
// Pod2 (Stale): 1.0.
229229
makePodMetric("pod2", 0, 0.2, baseTime.Add(-300*time.Millisecond)),
230230
},
231-
// Max(0.2, 1.0) = 1.0
232-
wantSaturation: 1.0,
231+
// Avg(0.2, 1.0) = 0.6
232+
wantSaturation: 0.6,
233233
},
234234
{
235235
name: "Multiple pods, one good, one bad (high queue)",
@@ -239,8 +239,8 @@ func TestDetector_Saturation(t *testing.T) {
239239
// Pod2 (Bad): Q=15/5(3.0). Max=3.0.
240240
makePodMetric("pod2", 15, 0.2, baseTime),
241241
},
242-
// Max(0.2, 3.0) = 3.0 (hottest endpoint drives the pool)
243-
wantSaturation: 3.0,
242+
// Avg(0.2, 3.0) = 1.6
243+
wantSaturation: 1.6,
244244
},
245245
{
246246
name: "Multiple pods, all bad capacity",
@@ -252,8 +252,8 @@ func TestDetector_Saturation(t *testing.T) {
252252
// Pod3 (High KV): 0.99/0.90 = 1.1
253253
makePodMetric("pod3", 1, 0.99, baseTime),
254254
},
255-
// Max(1.0, 4.0, 1.1) = 4.0
256-
wantSaturation: 4.0,
255+
// Avg(1.0, 4.0, 1.1) = 6.1 / 3 = 2.033...
256+
wantSaturation: (1.0 + 4.0 + 1.1) / 3.0,
257257
},
258258
{
259259
name: "Queue depth exactly at threshold",

0 commit comments

Comments
 (0)