Skip to content

Commit eb4720b

Browse files
committed
feat: graduate flow control from experimental, keep the gate opt-in
The flowControl feature gate stays disabled by default: saturation detection needs per-deployment tuning, so enabling the layer is an explicit decision by an operator who has read the tuning guidance. The graduation is everything around that decision: - Log lines no longer call the layer experimental. - The loader warns when a flowControl config section is present while the gate is off. Everything in the section except saturationDetector (which the legacy admission path also uses) was silently ignored, and with an opt-in gate that misconfiguration is easy to hit. - A test pins the registered default so changing it is deliberate. - Docs cover enablement, queue memory sizing, per-replica flow control state in Active-Active, and the dependency on the EPP's model-server metrics scrape staying fresh. Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 1026043 commit eb4720b

10 files changed

Lines changed: 203 additions & 11 deletions

File tree

apix/config/v1alpha1/endpointpickerconfig_types.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ type EndpointPickerConfig struct {
3434
metav1.TypeMeta `json:",inline"`
3535

3636
// +optional
37-
// FeatureGates is a set of flags that enable various experimental features with the EPP.
38-
// If omitted none of these experimental features will be enabled.
37+
// FeatureGates is a set of flags that toggle optional EPP features. Each entry is a gate name,
38+
// optionally suffixed with "=true" or "=false" (a bare name means "=true"). Gates carry
39+
// per-gate defaults that apply when omitted; some default to enabled.
3940
FeatureGates FeatureGates `json:"featureGates,omitempty"`
4041

4142
// +required
@@ -186,7 +187,8 @@ func (sp SchedulingPlugin) String() string {
186187
return "{" + strings.Join(parts, ", ") + "}"
187188
}
188189

189-
// FeatureGates is a set of flags that enable various experimental features with the EPP
190+
// FeatureGates is a set of flags that toggle optional EPP features ("name", "name=true", or
191+
// "name=false"); omitted gates use their registered defaults.
190192
type FeatureGates []string
191193

192194
func (fg FeatureGates) String() string {

cmd/epp/runner/feature_gate_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,8 @@ featureGates:
6262
// FlowControlConfig, and the flow registry is exposed as the priority band control plane;
6363
// - gate off: the LegacyAdmissionController is wired and no flow control config is built.
6464
//
65-
// The "no featureGates stanza" case reads the gate's registered default from the parsed
66-
// feature-gate map instead of hardcoding it, so this test keeps passing unchanged when the gate
67-
// flips to enabled-by-default (#2104) and pins that the flip actually changes the default wiring.
65+
// The "no featureGates stanza" case pins the registered default: flow control is an explicit
66+
// opt-in, so changing the default is a deliberate act that must edit this test.
6867
func TestFlowControlFeatureGateAdmissionControlWiring(t *testing.T) {
6968
boolPtr := func(b bool) *bool { return &b }
7069
testCases := []struct {
@@ -119,6 +118,8 @@ featureGates:
119118
wantEnabled = *tc.wantEnabled
120119
require.Equal(t, wantEnabled, r.featureGates[flowcontrol.FeatureGate],
121120
"the loader should honor the explicit featureGates stanza")
121+
} else {
122+
require.False(t, wantEnabled, "the flowControl gate is registered as disabled by default")
122123
}
123124

124125
ds := datastore.NewDatastore(ctx, r.setupMetricsCollection(opts))

cmd/epp/runner/runner.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -874,13 +874,13 @@ func (r *Runner) initAdmissionControl(
874874
endpointCandidates contracts.EndpointCandidates,
875875
) (contracts.EndpointCandidates, requestcontrol.AdmissionController, contracts.PriorityBandControlPlane) {
876876
if !r.featureGates[flowcontrol.FeatureGate] {
877-
setupLog.Info("Experimental Flow Control layer is disabled, using legacy admission control")
877+
setupLog.Info("Flow Control layer is disabled via the flowControl feature gate, using legacy admission control")
878878
return endpointCandidates,
879879
requestcontrol.NewLegacyAdmissionController(eppConfig.SaturationDetector, endpointCandidates),
880880
nil
881881
}
882882
endpointCandidates = requestcontrol.NewCachedEndpointCandidates(ctx, endpointCandidates, 50*time.Millisecond)
883-
setupLog.Info("Initializing experimental Flow Control layer")
883+
setupLog.Info("Initializing Flow Control layer")
884884
registry := fcregistry.NewFlowRegistry(eppConfig.FlowControlConfig.Registry, setupLog)
885885
fc := fccontroller.NewFlowController(
886886
ctx,

docs/architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ RequestHandler:
195195
- When no parsers are configured, `openai-parser`, `anthropic-parser`, and `vllmhttp-parser` are used.
196196

197197
FlowControl:
198+
- The flow control admission layer itself is off by default; enable it with
199+
`featureGates: ["flowControl"]`.
198200
- `fcfs-ordering-policy`, `global-strict-fairness-policy`, and `static-usage-limit-policy` are configured when absent.
199201
- `utilization-detector` is configured as the saturation detector when none is set.
200202

docs/metrics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Unlabelled.
116116

117117
| Name | Type | Notes |
118118
|---|---|---|
119-
| `request_processing_duration_seconds` | Histogram | Time from request receipt until the request body has been handled. Includes admission control, so under the flow control feature gate this covers queue wait; `flow_control_request_queue_duration_seconds` separates it out. |
119+
| `request_processing_duration_seconds` | Histogram | Time from request receipt until the request body has been handled. Includes admission control, so with flow control enabled this covers queue wait; `flow_control_request_queue_duration_seconds` separates it out. |
120120
| `response_processing_duration_seconds` | Histogram | Sum of the per-chunk handler slices for a streamed response, so model-server generation time between chunks is excluded. For a non-streaming response, the interval from response headers to completion. |
121121

122122
### Plugin, info, and model rewrite

docs/operations.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ The EPP acts as the routing intelligence engine. Its resource usage scales prima
1919
#### Memory Allocation
2020
- **Base Memory**: EPP memory usage is relatively low and stable with small output token requests, but scales with the number of concurrent inflight requests.
2121
- **Inflight Requests Impact**: Memory usage increases with the number of concurrent inflight requests and the output (decode) token length.
22+
- **Flow Control Queues**: With flow control enabled, requests that cannot dispatch
23+
under saturation are buffered in EPP memory, including their request bodies. The buffered volume
24+
is bounded per priority band by `priorityBands[].maxRequests` (default 5000) and `maxBytes`
25+
(default 1G), which `defaultPriorityBand` sets as a template for bands you do not list; budget for
26+
the sum of the per-band `maxBytes` limits of the priority levels your traffic actually uses, on top
27+
of the inflight-request sizing above. The global `flowControl.maxRequests` / `maxBytes` caps
28+
default to unlimited, so set a global `maxBytes` under the container memory limit: at the per-band
29+
default, a handful of bands clears the sizing guidance below before any band cap engages. Lower
30+
these limits (or set a shorter `defaultRequestTTL`) to trade queueing for earlier shedding.
2231
- **Sizing Guidelines**:
2332
- For a request rate of 50 to 100 requests/second with 1k output tokens, EPP requires between **4 GiB and 6 GiB** of memory.
2433
- For workloads with longer output lengths (such as 5k output tokens), memory usage can reach **20+ GiB** due to the accumulation of state for concurrent inflight requests.
@@ -37,6 +46,10 @@ The EPP's scaling behavior and effectiveness are highly dependent on the configu
3746
| 3 | 2.7x |
3847
| 4 | 3.5x |
3948

49+
- **Note (Flow Control)**: Flow control state (queues, fairness accounting, and the saturation
50+
view) is per replica and not shared. In Active-Active mode, priority and fairness are enforced
51+
only within each replica's share of the traffic, and per-band capacity limits apply per
52+
replica, so the fleet-wide queued volume scales with the replica count.
4053
- **Warning (Prefix Routing)**: **Active-Active mode should be avoided when using approximate prefix routing.** Because EPP replicas do not share prefix state, each replica only has visibility into the prefix state of the requests it has individually handled. This partition of state significantly degrades prefix cache hit rates, making prefix caching highly inefficient.
4154
- For more technical details and context on EPP replica state sync and scaling limitations, see [Issue #1290](https://github.com/llm-d/llm-d-router/issues/1290).
4255

pkg/epp/config/loader/configloader.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ func InstantiateAndConfigure(
192192
if err != nil {
193193
return nil, fmt.Errorf("failed to load flow control config: %w", err)
194194
}
195+
} else if flowControlSettingsConfigured(rawConfig.FlowControl) {
196+
logger.Info("WARNING: the flowControl config section is set but the flowControl feature gate is disabled; its settings (other than saturationDetector) are ignored")
195197
}
196198

197199
parserRegistry, err := buildParserRegistry(rawConfig.RequestHandler.Parsers, handle, logger)
@@ -217,6 +219,19 @@ func InstantiateAndConfigure(
217219
}, nil
218220
}
219221

222+
// flowControlSettingsConfigured reports whether the flowControl config section carries settings
223+
// beyond the saturation detector. The saturation detector is honored by the legacy admission path
224+
// even when the flowControl feature gate is disabled, so it alone does not indicate ignored
225+
// configuration.
226+
func flowControlSettingsConfigured(fc *configapi.FlowControlConfig) bool {
227+
if fc == nil {
228+
return false
229+
}
230+
return fc.MaxBytes != nil || fc.MaxRequests != nil || fc.DefaultRequestTTL != nil ||
231+
fc.DefaultPriorityBand != nil || fc.DefaultNegativePriorityBand != nil ||
232+
len(fc.PriorityBands) > 0 || fc.UsageLimitPolicyPluginRef != ""
233+
}
234+
220235
func decodeRawConfig(configBytes []byte) (*configapi.EndpointPickerConfig, error) {
221236
cfg := &configapi.EndpointPickerConfig{}
222237
codecs := serializer.NewCodecFactory(scheme, serializer.EnableStrict)

pkg/epp/config/loader/configloader_test.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ func TestInstantiateAndConfigure(t *testing.T) {
561561
},
562562
},
563563
{
564-
name: "Ignored - Flow Control Config Present but FeatureGate Missing",
564+
name: "Ignored - Flow Control Config Present but FeatureGate Disabled",
565565
configText: successflowControlConfigDisabledText,
566566
wantErr: false,
567567
validate: func(t *testing.T, handle fwkplugin.Handle, rawCfg *configapi.EndpointPickerConfig, cfg *config.Config) {
@@ -850,6 +850,76 @@ func TestInstantiateAndConfigure(t *testing.T) {
850850
}
851851
}
852852

853+
// TestFlowControlConfigIgnoredWarning verifies that a flowControl config section combined with a
854+
// disabled flowControl feature gate logs a warning that the settings are ignored, and that the
855+
// warning stays silent otherwise. The silent cases carry the weight here: ensureSaturationDetector
856+
// populates FlowControl for every config, so a predicate that ignored its saturationDetector
857+
// exclusion would warn at every legacy-path startup.
858+
func TestFlowControlConfigIgnoredWarning(t *testing.T) {
859+
// Not parallel because it modifies the global plugin registry.
860+
registerTestPlugins(t)
861+
RegisterFeatureGate(flowcontrol.FeatureGate, false)
862+
863+
testCases := []struct {
864+
name string
865+
configText string
866+
gateEnabled bool
867+
wantWarn bool
868+
}{
869+
{
870+
name: "settings ignored under an explicit opt-out",
871+
configText: successflowControlConfigDisabledText,
872+
wantWarn: true,
873+
},
874+
{
875+
name: "settings ignored under the disabled default",
876+
configText: successFlowControlConfigNoGatesText,
877+
wantWarn: true,
878+
},
879+
{
880+
name: "opt-out with no flowControl section",
881+
configText: successFlowControlDisabledNoSectionText,
882+
},
883+
{
884+
name: "opt-out with only a saturation detector",
885+
configText: successFlowControlDisabledSaturationDetectorText,
886+
},
887+
{
888+
name: "settings honored when the gate is on",
889+
configText: successFlowControlConfigText,
890+
gateEnabled: true,
891+
},
892+
}
893+
894+
for _, tc := range testCases {
895+
t.Run(tc.name, func(t *testing.T) {
896+
writer := &strings.Builder{}
897+
logger := logging.NewTestLoggerWithWriter(writer)
898+
899+
rawConfig, _, err := LoadRawConfig([]byte(tc.configText), logger)
900+
require.NoError(t, err)
901+
902+
handle := testutils.NewTestHandle(context.Background())
903+
cfg, err := InstantiateAndConfigure(rawConfig, handle, logger)
904+
require.NoError(t, err)
905+
906+
if tc.gateEnabled {
907+
require.NotNil(t, cfg.FlowControlConfig, "flow control config should be built when the gate is on")
908+
} else {
909+
require.Nil(t, cfg.FlowControlConfig, "flow control config should not be built when the gate is disabled")
910+
}
911+
912+
if tc.wantWarn {
913+
require.Contains(t, writer.String(), "flowControl feature gate is disabled",
914+
"the ignored flowControl section should be called out in the logs")
915+
} else {
916+
require.NotContains(t, writer.String(), "flowControl feature gate is disabled",
917+
"nothing is being ignored, so the warning should stay silent")
918+
}
919+
})
920+
}
921+
}
922+
853923
// TestBuildDataLayerConfigEmptySourcesWarning verifies that an empty sources list
854924
// logs a warning but does not return an error.
855925
func TestBuildDataLayerConfigEmptySourcesWarning(t *testing.T) {

pkg/epp/config/loader/testdata_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,59 @@ schedulingProfiles:
274274
- name: default
275275
plugins:
276276
- pluginRef: maxScore
277-
featureGates: [] # Explicitly empty
277+
featureGates: ["flowControl=false"] # Explicit opt-out.
278278
flowControl:
279279
maxBytes: "1024"
280280
`
281281

282+
// successFlowControlConfigNoGatesText carries flowControl settings with no featureGates stanza, so
283+
// the section is ignored under the gate's disabled default.
284+
const successFlowControlConfigNoGatesText = `
285+
apiVersion: llm-d.ai/v1alpha1
286+
kind: EndpointPickerConfig
287+
plugins:
288+
- name: maxScore
289+
type: max-score-picker
290+
schedulingProfiles:
291+
- name: default
292+
plugins:
293+
- pluginRef: maxScore
294+
flowControl:
295+
maxBytes: "1024"
296+
`
297+
298+
// successFlowControlDisabledNoSectionText is the plain legacy-path config: an explicit opt-out with
299+
// nothing for the loader to report as ignored.
300+
const successFlowControlDisabledNoSectionText = `
301+
apiVersion: llm-d.ai/v1alpha1
302+
kind: EndpointPickerConfig
303+
plugins:
304+
- name: maxScore
305+
type: max-score-picker
306+
schedulingProfiles:
307+
- name: default
308+
plugins:
309+
- pluginRef: maxScore
310+
featureGates: ["flowControl=false"]
311+
`
312+
313+
// successFlowControlDisabledSaturationDetectorText pairs an explicit opt-out with a saturation
314+
// detector, which the legacy admission path honors and so must not be reported as ignored.
315+
const successFlowControlDisabledSaturationDetectorText = `
316+
apiVersion: llm-d.ai/v1alpha1
317+
kind: EndpointPickerConfig
318+
plugins:
319+
- name: maxScore
320+
type: max-score-picker
321+
schedulingProfiles:
322+
- name: default
323+
plugins:
324+
- pluginRef: maxScore
325+
featureGates: ["flowControl=false"]
326+
saturationDetector:
327+
pluginRef: utilization-detector
328+
`
329+
282330
// successComplexFlowControlConfigText tests that Flow Control configuration with custom plugins is correctly loaded.
283331
const successComplexFlowControlConfigText = `
284332
apiVersion: llm-d.ai/v1alpha1

pkg/epp/flowcontrol/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,47 @@ between the Routing and Scheduling layers. It decides *if* and *when* a request,
3030
mechanism for managing diverse SLOs, ensuring fairness among competing workloads, and maintaining system stability under
3131
high load.
3232

33+
### Enabling, disabling, and tuning
34+
35+
Flow control is disabled by default and is enabled explicitly, via the `flowControl` feature gate
36+
in the EPP config:
37+
38+
```yaml
39+
featureGates: ["flowControl"]
40+
```
41+
42+
With the gate off, the legacy saturation-only admission path is used. With it on, saturation no
43+
longer pushes excess requests into each model server's local queue: they wait in the EPP and
44+
dispatch by priority as capacity frees, with queue wait bounded by a TTL. Sheddable
45+
(negative-priority) requests buffer under the same TTL and capacity rules instead of being
46+
rejected immediately on saturation. Enabling the layer changes queueing behavior under load, so
47+
review the tuning knobs below as part of turning it on.
48+
49+
Setting a `flowControl:` config section while the gate is disabled logs a warning: everything in it
50+
except `saturationDetector` (which the legacy admission path also uses) is ignored.
51+
52+
Operationally, dispatch decisions depend on fresh endpoint metrics: saturation detection reads the
53+
model-server metrics that the EPP's own data layer scrapes from pods (this is independent of
54+
cluster monitoring such as Prometheus). Endpoints whose metrics are older than the detector's
55+
`metricsStalenessThreshold` count as fully saturated, so if the EPP loses its scrape path to all
56+
pods (network policy, metrics port change, a starved refresh loop), dispatch halts and queued
57+
requests eventually shed at their TTL. Keep the refresh interval comfortably inside the staleness
58+
threshold and monitor scrape health when running with flow control on.
59+
60+
Tuning knobs, all under the `flowControl:` config section:
61+
62+
* Per-band `maxRequests` / `maxBytes` — the shedding knobs. Lower them to reject excess load at the
63+
queue boundary instead of buffering it (for example, to approximate the legacy immediate-shed
64+
behavior for sheddable traffic).
65+
* `defaultRequestTTL` — the queue-wait budget, and the other way a request is shed. When the pool
66+
has no endpoints the queue acts as a scale-from-zero waiting room and requests hold for the full
67+
budget, so keep it under the client or gateway deadline unless you want requests to survive a cold
68+
start.
69+
* The priority-holdback usage-limit policy — a gating knob, not a shedding one. It lowers the
70+
admission ceiling for low-priority traffic as utilization rises, so that traffic waits in queue
71+
rather than being rejected; it sheds only by way of the two limits above. Configure it via
72+
`usageLimitPolicyPluginRef`.
73+
3374
### High Level Architecture
3475

3576
The following diagram illustrates the high-level dependency model and request flow for the system. It shows how

0 commit comments

Comments
 (0)