Skip to content

Commit 34478d1

Browse files
asharkhan3101ahg-gLukeAVanDrie
authored
feat(concurrency-detector): Add hybrid mode to concurrency detector (#1991)
* feat(concurrency-detector): Add hybrid mode to concurrency detector "hybrid" mode is a new concurrency detector mode that has been added which takes into account both concurrency based on token and request. Its saturation is computed as: `saturation = max(requestSaturation, tokenSaturation)` Using just request based concurrency is not enough as different workloads such as prefill heavy tends to consume more GPU compute, using just the tokenSaturation suffers from wrong estimation of output tokens which falls down in low ISL and high OSL workloads. Hybrid concurrency detector attempts to solve this problem by essentially capping whichever saturates first due to high ISL/OSL workload. Signed-off-by: mohammadkhan <mohammadkhan@digitalocean.com> * ci: run formatting Signed-off-by: mohammadkhan <mohammadkhan@digitalocean.com> * 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> * docs: review and fix the code comments Signed-off-by: mohammadkhan <mohammadkhan@digitalocean.com> --------- Signed-off-by: mohammadkhan <mohammadkhan@digitalocean.com> Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Co-authored-by: Luke Van Drie <lukevandrie@google.com>
1 parent f83049f commit 34478d1

4 files changed

Lines changed: 171 additions & 33 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"` or `"tokens"`. (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/config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ type apiConfig struct {
6262
// Valid values are:
6363
// - "requests": use discrete request counts for capacity accounting.
6464
// - "tokens": use estimated token counts for capacity accounting.
65+
// - "hybrid": evaluate each endpoint as the more constraining of its request and
66+
// token ratios; pool saturation is the average of these across endpoints.
6567
//
6668
// Defaults to "requests" if unset.
6769
ConcurrencyMode *concurrencyMode `json:"concurrencyMode,omitempty"`
@@ -85,6 +87,8 @@ const (
8587
modeRequests concurrencyMode = "requests"
8688
// modeTokens uses token count for concurrency detection.
8789
modeTokens concurrencyMode = "tokens"
90+
// modeHybrid uses endpoint average saturation across both request and token counts.
91+
modeHybrid concurrencyMode = "hybrid"
8892
)
8993

9094
const (
@@ -164,7 +168,7 @@ func validateConfig(cfg *apiConfig) error {
164168

165169
if cfg.ConcurrencyMode != nil {
166170
switch *cfg.ConcurrencyMode {
167-
case modeRequests, modeTokens:
171+
case modeRequests, modeTokens, modeHybrid:
168172
// Valid
169173
default:
170174
errs = append(errs, fmt.Errorf("unsupported concurrencyMode: %q", *cfg.ConcurrencyMode))

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

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -121,49 +121,69 @@ 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:
124+
// In "requests" and "tokens" mode it returns an aggregate signal, evaluated as:
125125
//
126-
// Saturation = Total Inflight Requests / Total MaxConcurrency Capacity.
126+
// Saturation = Total Inflight / Total Capacity.
127+
//
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.
127132
func (d *detector) Saturation(_ context.Context, endpoints []datalayer.Endpoint) float64 {
128133
if len(endpoints) == 0 {
129134
return 1.0
130135
}
131136

132-
var totalInflight, totalCapacity int64
137+
var reqInflight, reqCapacity, tokInflight, tokCapacity int64
138+
var endpointCount int
139+
var hybridSatSum float64
133140
for _, e := range endpoints {
134141
if e == nil {
135142
continue
136143
}
137144

138-
if d.config.mode == modeTokens {
139-
totalCapacity += d.config.maxTokenConcurrency
140-
} else {
141-
totalCapacity += d.config.maxConcurrency
142-
}
145+
endpointCount++
146+
reqCapacity += d.config.maxConcurrency
147+
tokCapacity += d.config.maxTokenConcurrency
143148

144149
if e.GetMetadata() == nil {
145150
continue
146151
}
147152

148153
load := d.getLoad(e.GetAttributes())
154+
reqInflight += load.Requests
155+
tokInflight += load.Tokens
156+
hybridSatSum += max(
157+
ratio(load.Requests, d.config.maxConcurrency),
158+
ratio(load.Tokens, d.config.maxTokenConcurrency),
159+
)
160+
}
149161

150-
if d.config.mode == modeTokens {
151-
totalInflight += load.Tokens
152-
} else {
153-
totalInflight += load.Requests
162+
switch d.config.mode {
163+
case modeTokens:
164+
return ratio(tokInflight, tokCapacity)
165+
case modeHybrid:
166+
if endpointCount == 0 {
167+
return 1.0
154168
}
169+
return hybridSatSum / float64(endpointCount)
170+
default:
171+
return ratio(reqInflight, reqCapacity)
155172
}
173+
}
156174

157-
if totalCapacity == 0 {
175+
// ratio computes inflight/capacity, failing closed (1.0) when capacity is zero.
176+
func ratio(inflight, capacity int64) float64 {
177+
if capacity == 0 {
158178
return 1.0
159179
}
160-
161-
return float64(totalInflight) / float64(totalCapacity)
180+
return float64(inflight) / float64(capacity)
162181
}
163182

164183
// Filter blocks traffic to specific endpoints that are physically saturated or exceeding their safety limits.
165184
//
166-
// It applies a relaxed limit (MaxConcurrency * (1 + Headroom)) to allow for scheduling flexibility and burst tolerance.
185+
// It applies a relaxed limit (Capacity * (1 + Headroom)) to allow for scheduling flexibility and burst tolerance.
186+
// In "hybrid" mode an endpoint is dropped when either its request load or its token load reaches the limit.
167187
func (d *detector) Filter(
168188
_ context.Context,
169189
_ *fwksched.InferenceRequest,
@@ -172,28 +192,30 @@ func (d *detector) Filter(
172192
// Pre-allocate assuming most endpoints will pass the filter to minimize allocations.
173193
filtered := make([]fwksched.Endpoint, 0, len(endpoints))
174194

175-
var limit int64
176-
if d.config.mode == modeTokens {
177-
limit = int64(float64(d.config.maxTokenConcurrency) * (1.0 + d.config.headroom))
178-
} else {
179-
limit = int64(float64(d.config.maxConcurrency) * (1.0 + d.config.headroom))
180-
}
195+
reqLimit := int64(float64(d.config.maxConcurrency) * (1.0 + d.config.headroom))
196+
tokLimit := int64(float64(d.config.maxTokenConcurrency) * (1.0 + d.config.headroom))
181197

182198
for _, e := range endpoints {
183199
if e == nil {
184200
continue
185201
}
186202
load := d.getLoad(e)
187203

188-
if d.config.mode == modeTokens {
189-
if load.Tokens < limit {
190-
filtered = append(filtered, e)
191-
}
192-
} else {
193-
if load.Requests < limit {
194-
filtered = append(filtered, e)
195-
}
204+
if d.admits(load, reqLimit, tokLimit) {
205+
filtered = append(filtered, e)
196206
}
197207
}
198208
return filtered
199209
}
210+
211+
// admits reports whether an endpoint is below its safety limit for the active mode.
212+
func (d *detector) admits(load *attrconcurrency.InFlightLoad, reqLimit, tokLimit int64) bool {
213+
switch d.config.mode {
214+
case modeTokens:
215+
return load.Tokens < tokLimit
216+
case modeHybrid:
217+
return load.Requests < reqLimit && load.Tokens < tokLimit
218+
default:
219+
return load.Requests < reqLimit
220+
}
221+
}

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ func TestConcurrencyDetectorFactory(t *testing.T) {
110110
configJSON: []byte(`{"headroom": -0.5}`),
111111
wantError: true,
112112
},
113+
{
114+
name: "valid hybrid mode",
115+
configJSON: []byte(`{"concurrencyMode": "hybrid", "maxConcurrency": 10, "maxTokenConcurrency": 100}`),
116+
wantError: false,
117+
},
113118
{
114119
name: "invalid mode",
115120
configJSON: []byte(`{"concurrencyMode": "magic"}`),
@@ -490,6 +495,111 @@ func TestDetector_TokenDeleteEndpoint(t *testing.T) {
490495
require.InDelta(t, 0.0, detector.Saturation(ctx, candidates), 1e-6, "expected clean state after DeleteEndpoint")
491496
}
492497

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.
500+
func TestDetector_HybridSaturation(t *testing.T) {
501+
t.Parallel()
502+
503+
config := config{
504+
mode: modeHybrid,
505+
maxConcurrency: 10,
506+
maxTokenConcurrency: 100,
507+
}
508+
509+
type endpointLoad struct {
510+
requests int64
511+
tokens int64
512+
}
513+
514+
tests := []struct {
515+
name string
516+
endpoints map[string]endpointLoad
517+
wantSaturation float64
518+
}{
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+
},
538+
}
539+
540+
for _, tc := range tests {
541+
t.Run(tc.name, func(t *testing.T) {
542+
t.Parallel()
543+
reg := newLocalRegistry()
544+
ctx := context.Background()
545+
detector := newDetector("test-detector", config, logr.Discard())
546+
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)
557+
require.InDelta(t, tc.wantSaturation, got, 1e-6, "hybrid saturation mismatch")
558+
})
559+
}
560+
}
561+
562+
// TestDetector_HybridFilter verifies hybrid mode drops an endpoint when either dimension hits its limit.
563+
func TestDetector_HybridFilter(t *testing.T) {
564+
t.Parallel()
565+
566+
// headroom 0.0 -> limits are exactly maxConcurrency (10) and maxTokenConcurrency (100).
567+
config := config{
568+
mode: modeHybrid,
569+
maxConcurrency: 10,
570+
maxTokenConcurrency: 100,
571+
}
572+
ctx := context.Background()
573+
574+
tests := []struct {
575+
name string
576+
requests int64
577+
tokens int64
578+
wantKept int
579+
}{
580+
{name: "below_both_limits", requests: 5, tokens: 50, wantKept: 1},
581+
{name: "request_limit_reached", requests: 10, tokens: 50, wantKept: 0},
582+
{name: "token_limit_reached", requests: 5, tokens: 100, wantKept: 0},
583+
{name: "both_over", requests: 20, tokens: 200, wantKept: 0},
584+
}
585+
586+
for _, tc := range tests {
587+
t.Run(tc.name, func(t *testing.T) {
588+
t.Parallel()
589+
reg := newLocalRegistry()
590+
detector := newDetector("test-detector", config, logr.Discard())
591+
endpointName := "endpoint-a"
592+
reg.update(fullEndpointName(endpointName), func(load *attrconcurrency.InFlightLoad) {
593+
load.Requests = tc.requests
594+
load.Tokens = tc.tokens
595+
})
596+
597+
kept := detector.Filter(ctx, nil, []fwksched.Endpoint{newStubSchedulingEndpoint(reg, endpointName)})
598+
require.Len(t, kept, tc.wantKept, "hybrid filter mismatch")
599+
})
600+
}
601+
}
602+
493603
// TestDetector_ConcurrencyStress performs race condition check.
494604
func TestDetector_ConcurrencyStress(t *testing.T) {
495605
t.Parallel()

0 commit comments

Comments
 (0)