Skip to content

Commit c655ab0

Browse files
committed
fix: calculate pool saturation as endpoint average
Pool saturation is defined as average of saturation across each endpoint, this is exactly how it is computed but in multi-dimensional saturation cases such as hybrid, this wasn't true. This fixes it. Signed-off-by: mohammadkhan <mohammadkhan@digitalocean.com>
1 parent 9f7f25a commit c655ab0

3 files changed

Lines changed: 57 additions & 21 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ The detector implements the `SaturationDetector` interface to provide a utilizat
1515

1616
In token mode, both numerator and denominator are evaluated in tokens: the aggregate inflight token count divided by the sum of all endpoints' MaxTokenConcurrency.
1717

18-
**Heterogeneous Deployments:** Because this detector calculates saturation globally as a single aggregate fraction, it utilizes an aggregate queueing model. In deployments with heterogeneous compute (e.g., mixing H100 and L4 nodes), this heavily biases the pool saturation metric toward the state of the larger nodes. Contrast this with the Utilization Detector, which evaluates saturation as an unweighted average of individual endpoint scores.
18+
Hybrid mode is the exception: rather than one aggregate fraction, it evaluates each endpoint's saturation as the larger of its request and token ratios and reports the unweighted average across endpoints. This prevents distinct endpoints saturating on different dimensions from being masked by aggregate ratios that each remain low.
19+
20+
**Heterogeneous Deployments:** Because this detector calculates saturation globally as a single aggregate fraction (in requests and tokens mode), it utilizes an aggregate queueing model. In deployments with heterogeneous compute (e.g., mixing H100 and L4 nodes), this heavily biases the pool saturation metric toward the state of the larger nodes. Contrast this with the Utilization Detector, which evaluates saturation as an unweighted average of individual endpoint scores.
1921

2022
### Role in Scheduling (The Traffic Shaper)
2123
The detector implements the `Filter` interface to protect individual endpoints. It removes endpoints from candidate lists if their local inflight count exceeds the safety limit:
@@ -34,7 +36,7 @@ The plugin internally tracks active concurrency by hooking into the request life
3436

3537
The plugin accepts JSON parameters decoding to the following fields:
3638

37-
- `concurrencyMode` (`string`): Evaluation mode. Valid values are `"requests"`, `"tokens"`, or `"hybrid"`. In `"hybrid"` mode both request and token accounting are evaluated and the more constraining dimension wins: pool saturation is the larger of the two ratios, and an endpoint is filtered out when either its request load or its token load reaches the limit. (Default: `"requests"`)
39+
- `concurrencyMode` (`string`): Evaluation mode. Valid values are `"requests"`, `"tokens"`, or `"hybrid"`. In `"hybrid"` mode both request and token accounting are evaluated. Pool saturation is computed per endpoint as the larger of that endpoint's request and token ratios, then averaged across endpoints, so an endpoint saturated on either dimension is reflected even when distinct endpoints saturate on different dimensions. An endpoint is filtered out when either its request load or its token load reaches the limit. (Default: `"requests"`)
3840
- `maxConcurrency` (`int64`): Maximum requests in flight. Serves as the "ideal" request capacity for a single endpoint. Must be > 0. (Default: `100`)
3941
- `maxTokenConcurrency` (`int64`): Maximum tokens in flight. The "tokens" mode equivalent of `maxConcurrency`. Must be > 0. (Default: `1000000`)
4042
- `headroom` (`float64`): Allowed burst capacity above the ideal threshold, expressed as a fraction (e.g., `0.2` for 20%). Must be >= 0.0. (Default: `0.0`)

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,24 +121,28 @@ func (d *detector) getLoad(m datalayer.AttributeMap) *attrconcurrency.InFlightLo
121121

122122
// Saturation calculates the saturation level of the pool.
123123
//
124-
// It returns an aggregate saturation signal where each dimension is evaluated as:
124+
// In "requests" and "tokens" mode it returns an aggregate signal, evaluated as:
125125
//
126126
// Saturation = Total Inflight / Total Capacity.
127127
//
128-
// In "hybrid" mode both the request and token dimensions are computed and the larger
129-
// (more saturated) of the two is returned, so the pool reports saturated as soon as
130-
// either dimension is exhausted.
128+
// In "hybrid" mode saturation is instead evaluated per endpoint as
129+
// max(requestRatio, tokenRatio) and averaged across endpoints. Evaluating each
130+
// endpoint independently ensures an endpoint saturated on either dimension is
131+
// reflected in the pool signal.
131132
func (d *detector) Saturation(_ context.Context, endpoints []datalayer.Endpoint) float64 {
132133
if len(endpoints) == 0 {
133134
return 1.0
134135
}
135136

136137
var reqInflight, reqCapacity, tokInflight, tokCapacity int64
138+
var endpointCount int
139+
var hybridSatSum float64
137140
for _, e := range endpoints {
138141
if e == nil {
139142
continue
140143
}
141144

145+
endpointCount++
142146
reqCapacity += d.config.maxConcurrency
143147
tokCapacity += d.config.maxTokenConcurrency
144148

@@ -149,13 +153,20 @@ func (d *detector) Saturation(_ context.Context, endpoints []datalayer.Endpoint)
149153
load := d.getLoad(e.GetAttributes())
150154
reqInflight += load.Requests
151155
tokInflight += load.Tokens
156+
hybridSatSum += max(
157+
ratio(load.Requests, d.config.maxConcurrency),
158+
ratio(load.Tokens, d.config.maxTokenConcurrency),
159+
)
152160
}
153161

154162
switch d.config.mode {
155163
case modeTokens:
156164
return ratio(tokInflight, tokCapacity)
157165
case modeHybrid:
158-
return max(ratio(reqInflight, reqCapacity), ratio(tokInflight, tokCapacity))
166+
if endpointCount == 0 {
167+
return 1.0
168+
}
169+
return hybridSatSum / float64(endpointCount)
159170
default:
160171
return ratio(reqInflight, reqCapacity)
161172
}

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

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,8 @@ func TestDetector_TokenDeleteEndpoint(t *testing.T) {
495495
require.InDelta(t, 0.0, detector.Saturation(ctx, candidates), 1e-6, "expected clean state after DeleteEndpoint")
496496
}
497497

498-
// TestDetector_HybridSaturation verifies hybrid mode reports the more saturated dimension.
498+
// TestDetector_HybridSaturation verifies hybrid mode evaluates each endpoint as the more
499+
// saturated of its request and token dimensions and averages the result across endpoints.
499500
func TestDetector_HybridSaturation(t *testing.T) {
500501
t.Parallel()
501502

@@ -505,17 +506,35 @@ func TestDetector_HybridSaturation(t *testing.T) {
505506
maxTokenConcurrency: 100,
506507
}
507508

509+
type endpointLoad struct {
510+
requests int64
511+
tokens int64
512+
}
513+
508514
tests := []struct {
509515
name string
510-
requests int64
511-
tokens int64
516+
endpoints map[string]endpointLoad
512517
wantSaturation float64
513518
}{
514-
{name: "empty", requests: 0, tokens: 0, wantSaturation: 0.0},
515-
{name: "tokens_dominate", requests: 2, tokens: 80, wantSaturation: 0.8}, // max(0.2, 0.8)
516-
{name: "requests_dominate", requests: 9, tokens: 10, wantSaturation: 0.9}, // max(0.9, 0.1)
517-
{name: "request_dimension_saturates_first", requests: 10, tokens: 5, wantSaturation: 1.0},
518-
{name: "token_dimension_saturates_first", requests: 1, tokens: 100, wantSaturation: 1.0},
519+
{name: "empty", endpoints: map[string]endpointLoad{"endpoint-a": {0, 0}}, wantSaturation: 0.0},
520+
// Single endpoint: saturation is the more saturated of its two dimensions.
521+
{name: "tokens_dominate", endpoints: map[string]endpointLoad{"endpoint-a": {2, 80}}, wantSaturation: 0.8}, // max(0.2, 0.8)
522+
{name: "requests_dominate", endpoints: map[string]endpointLoad{"endpoint-a": {9, 10}}, wantSaturation: 0.9}, // max(0.9, 0.1)
523+
{name: "request_dimension_saturates_first", endpoints: map[string]endpointLoad{"endpoint-a": {10, 5}}, wantSaturation: 1.0},
524+
{name: "token_dimension_saturates_first", endpoints: map[string]endpointLoad{"endpoint-a": {1, 100}}, wantSaturation: 1.0},
525+
// Distinct endpoints saturated on different dimensions each report 1.0, so the pool
526+
// averages to 1.0.
527+
{
528+
name: "endpoints_saturated_on_different_dimensions",
529+
endpoints: map[string]endpointLoad{"endpoint-a": {10, 0}, "endpoint-b": {0, 100}},
530+
wantSaturation: 1.0,
531+
},
532+
// One saturated endpoint and one idle endpoint average to the midpoint.
533+
{
534+
name: "per_endpoint_average",
535+
endpoints: map[string]endpointLoad{"endpoint-a": {10, 0}, "endpoint-b": {0, 0}},
536+
wantSaturation: 0.5, // mean(1.0, 0.0)
537+
},
519538
}
520539

521540
for _, tc := range tests {
@@ -524,13 +543,17 @@ func TestDetector_HybridSaturation(t *testing.T) {
524543
reg := newLocalRegistry()
525544
ctx := context.Background()
526545
detector := newDetector("test-detector", config, logr.Discard())
527-
endpointName := "endpoint-a"
528-
reg.update(fullEndpointName(endpointName), func(load *attrconcurrency.InFlightLoad) {
529-
load.Requests = tc.requests
530-
load.Tokens = tc.tokens
531-
})
532546

533-
got := detector.Saturation(ctx, []datalayer.Endpoint{newFakeEndpoint(reg, endpointName)})
547+
candidates := make([]datalayer.Endpoint, 0, len(tc.endpoints))
548+
for name, l := range tc.endpoints {
549+
reg.update(fullEndpointName(name), func(load *attrconcurrency.InFlightLoad) {
550+
load.Requests = l.requests
551+
load.Tokens = l.tokens
552+
})
553+
candidates = append(candidates, newFakeEndpoint(reg, name))
554+
}
555+
556+
got := detector.Saturation(ctx, candidates)
534557
require.InDelta(t, tc.wantSaturation, got, 1e-6, "hybrid saturation mismatch")
535558
})
536559
}

0 commit comments

Comments
 (0)