Skip to content

Commit 391cb75

Browse files
committed
feat(disaggregation): gating at head; skip when header pins or ≤1 revision
Moves the gating filter to the head of the scheduler filter chain (prepended after strict, so it ends up in front of it), making the revision-axis decision the very first step per request. Chain order becomes: gating → strict → operator → prefer Only one of gating/strict actually makes the revision decision on any given request; the other detects the situation and short-circuits. Gating skips (passthrough, no cache List, no RNG) when: 1. The revision-axis strict header is set — strict downstream will pin the revision. Gating firing here would either waste work or stochastic-pick a revision that strict then rejects (503). 2. The candidate pool contains ≤1 unique revision — nothing to shape, either because the fleet has one revision or an upstream filter already narrowed. Otherwise gating does its weighted-random-pick as before (weight per revision = Σ Ready pod count over gating.requireRoles.values), drops uncovered revisions, and keeps only the chosen revision's pods. Everything downstream of the head now sees a single-revision candidate pool. Operator-declared filters and prefer no longer walk multi- revision pools when the decision has already been made. Also renames scanCoverage → readyPodsByRevisionRole. The old name implied "checks coverage" but the function also returns raw per-role counts used by the gating weights; the new name describes the data structure directly. Tests: - wire_test.go: order assertion flipped to gating < strict < operator < prefer. - New: TestGatingFilter_SkipsWhenRevisionHeaderPresent — panicking rand01 asserts the fast path fires when the header is set. - New: TestGatingFilter_SkipsWhenSingleRevisionInPool — same, for the ≤1-revision fast path. Live-verified on kind against the three shapes covered by scripts/verify-slow-transition.sh (10p10d, 2p20d, 20p2d) — all steps land within ±10pp of the expected cross-role weighted share; zero cross-revision mismatches, zero request failures during transitions. Signed-off-by: Mathis Felardos <mathis@mistral.ai>
1 parent 2dbd0a9 commit 391cb75

6 files changed

Lines changed: 146 additions & 46 deletions

File tree

pkg/epp/disaggregation/controller.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,11 @@ func (c *Controller) ResponseHeader(_ context.Context, _ *fwksched.InferenceRequ
151151
}
152152
}
153153

154-
// scanCoverage lists Ready pods matching the controller's scope and groups
154+
// readyPodsByRevisionRole lists Ready pods matching the controller's scope and groups
155155
// them by (revision → role → count). One cache-backed List per call. Used
156156
// by the gating filter (per request, for both coverage AND revision-weight
157157
// computation) and by boot validation.
158-
func (c *Controller) scanCoverage(ctx context.Context) (revisions map[string]struct{}, roleCounts map[string]map[string]int, err error) {
158+
func (c *Controller) readyPodsByRevisionRole(ctx context.Context) (revisions map[string]struct{}, roleCounts map[string]map[string]int, err error) {
159159
var podList corev1.PodList
160160
if err := c.reader.List(ctx, &podList,
161161
client.InNamespace(c.namespace),

pkg/epp/disaggregation/controller_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,49 @@ func TestGatingFilter_NoGatingConfigIsNoop(t *testing.T) {
433433
}
434434
}
435435

436+
func TestGatingFilter_SkipsWhenRevisionHeaderPresent(t *testing.T) {
437+
// A request with the revision-axis strict header means strict downstream
438+
// will pin the revision; gating firing here would either waste work or,
439+
// worse, weight-pick a revision that strict then rejects (503). The
440+
// filter must passthrough without reading the cache — assert by using a
441+
// panicking rand01 as a canary.
442+
c := gatingFixture(t, validConfig(), map[string]map[string]int{
443+
"v1": {"prefill": 2, "decode": 18},
444+
"v2": {"prefill": 1, "decode": 2},
445+
})
446+
f := newGatingFilter(c)
447+
f.rand01 = func() float64 { panic("rand01 must not be called when the revision header is set") }
448+
pods := []fwksched.Endpoint{
449+
endpoint("p1a", revLabels("v1")),
450+
endpoint("p2", revLabels("v2")),
451+
}
452+
req := &fwksched.InferenceRequest{Headers: map[string]string{"x-disagg-revision": "v1"}}
453+
got := f.Filter(context.Background(), req, pods)
454+
if len(got) != len(pods) {
455+
t.Fatalf("expected passthrough of all %d pods, got %d", len(pods), len(got))
456+
}
457+
}
458+
459+
func TestGatingFilter_SkipsWhenSingleRevisionInPool(t *testing.T) {
460+
// If the candidate pool already has only one unique revision — e.g.
461+
// because the fleet has one revision, or an upstream filter narrowed —
462+
// gating has nothing to shape and must skip. Panicking rand01 asserts
463+
// the fast path fires.
464+
c := gatingFixture(t, validConfig(), map[string]map[string]int{
465+
"v1": {"prefill": 3, "decode": 3},
466+
})
467+
f := newGatingFilter(c)
468+
f.rand01 = func() float64 { panic("rand01 must not be called with a single-revision pool") }
469+
pods := []fwksched.Endpoint{
470+
endpoint("p1", revLabels("v1")),
471+
endpoint("p2", revLabels("v1")),
472+
}
473+
got := f.Filter(context.Background(), nil, pods)
474+
if len(got) != len(pods) {
475+
t.Fatalf("expected passthrough of all %d pods, got %d", len(pods), len(got))
476+
}
477+
}
478+
436479
func TestGatingFilter_DisabledModeIsNoop(t *testing.T) {
437480
cfg := validConfig()
438481
cfg.Gating.Mode = GatingModeDisabled

pkg/epp/disaggregation/filter.go

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@ func (f *modeSelectorsFilter) Filter(ctx context.Context, request *fwksched.Infe
4848
return f.controller.filterSelectors(ctx, request, pods, f.keepFn)
4949
}
5050

51-
// gatingFilter is where the disaggregation controller actually shapes
52-
// traffic. Two things happen per Filter call:
51+
// gatingFilter is where the disaggregation controller decides the revision
52+
// axis when no client header has done so. Runs at the HEAD of the filter
53+
// chain. Two things happen per Filter call:
5354
//
5455
// 1. Drop any candidate whose revision fails the coverage check —
5556
// revisions missing pods on any listed role (rollout drift) are
@@ -65,6 +66,14 @@ func (f *modeSelectorsFilter) Filter(ctx context.Context, request *fwksched.Infe
6566
// the picker choice in the operator YAML: prefill traffic to revision R
6667
// converges on (crossRolePods(R) / Σ crossRolePods) even when prefill and
6768
// decode replica counts differ per revision (2p+18d vs. 1p+2d etc.).
69+
//
70+
// The filter short-circuits (passthrough, no cache read) when:
71+
// - The request carries the revision-axis strict header — the strict
72+
// filter downstream will pin the revision, we shouldn't stochastic-
73+
// pick something that would then get dropped as a mismatch (503).
74+
// - The candidate pool contains at most one unique revision — nothing
75+
// to shape, either because the fleet has one revision or because an
76+
// upstream filter already narrowed things.
6877
type gatingFilter struct {
6978
controller *Controller
7079
typedName fwkplugin.TypedName
@@ -89,35 +98,37 @@ func newGatingFilter(controller *Controller) *gatingFilter {
8998

9099
func (f *gatingFilter) TypedName() fwkplugin.TypedName { return f.typedName }
91100

92-
func (f *gatingFilter) Filter(ctx context.Context, _ *fwksched.InferenceRequest, pods []fwksched.Endpoint) []fwksched.Endpoint {
101+
func (f *gatingFilter) Filter(ctx context.Context, request *fwksched.InferenceRequest, pods []fwksched.Endpoint) []fwksched.Endpoint {
93102
gating := f.controller.config.Gating
94103
revisionLabelKey := f.controller.revisionLabelKey
95104
if !gating.Active() || revisionLabelKey == "" {
96105
return append(make([]fwksched.Endpoint, 0, len(pods)), pods...)
97106
}
107+
108+
// Fast path: header-pinned. Strict downstream will do the narrowing.
109+
if f.hasRevisionHeader(request) {
110+
return append(make([]fwksched.Endpoint, 0, len(pods)), pods...)
111+
}
112+
113+
// Fast path: pool already has ≤1 unique revision. Nothing to shape.
114+
seenRevisions := uniqueRevisions(pods, revisionLabelKey)
115+
if len(seenRevisions) <= 1 {
116+
return append(make([]fwksched.Endpoint, 0, len(pods)), pods...)
117+
}
118+
98119
requiredRoles := gating.RequireRoles.Values
99120

100-
_, roleCounts, err := f.controller.scanCoverage(ctx)
121+
_, roleCounts, err := f.controller.readyPodsByRevisionRole(ctx)
101122
if err != nil {
102123
// Cache-backed List failing is unexpected; be pessimistic and drop
103124
// everything rather than route to potentially-broken revisions.
104125
return nil
105126
}
106127

107-
// Collect the revisions present in the candidate pool and their
108-
// cross-role weights (0 if any required role has no Ready pod → gated).
109-
weights := make(map[string]int)
110-
for _, endpoint := range pods {
111-
if endpoint == nil || endpoint.GetMetadata() == nil {
112-
continue
113-
}
114-
revision := endpoint.GetMetadata().Labels[revisionLabelKey]
115-
if revision == "" {
116-
continue
117-
}
118-
if _, already := weights[revision]; already {
119-
continue
120-
}
128+
// Weight per revision in the pool (0 if any required role has no Ready
129+
// pod → gated).
130+
weights := make(map[string]int, len(seenRevisions))
131+
for revision := range seenRevisions {
121132
weights[revision] = crossRoleWeight(roleCounts[revision], requiredRoles)
122133
}
123134

@@ -162,6 +173,41 @@ func crossRoleWeight(perRole map[string]int, required []string) int {
162173
return total
163174
}
164175

176+
// hasRevisionHeader reports whether the request carries the header that
177+
// pins the revision axis. Convention: the revision axis is the first
178+
// entry in Selectors — same source as controller.revisionLabelKey — and
179+
// only counts when it's a strict-mode selector (prefer mode is a hint,
180+
// not a pin).
181+
func (f *gatingFilter) hasRevisionHeader(request *fwksched.InferenceRequest) bool {
182+
if request == nil {
183+
return false
184+
}
185+
selectors := f.controller.config.Selectors
186+
if len(selectors) == 0 {
187+
return false
188+
}
189+
revSelector := selectors[0]
190+
if revSelector.Mode != ModeStrict {
191+
return false
192+
}
193+
return request.Headers[revSelector.HeaderName] != ""
194+
}
195+
196+
// uniqueRevisions returns the set of distinct revision-label values in
197+
// the candidate pool. Used only by the gating fast path.
198+
func uniqueRevisions(pods []fwksched.Endpoint, revisionLabelKey string) map[string]struct{} {
199+
seen := make(map[string]struct{})
200+
for _, endpoint := range pods {
201+
if endpoint == nil || endpoint.GetMetadata() == nil {
202+
continue
203+
}
204+
if revision := endpoint.GetMetadata().Labels[revisionLabelKey]; revision != "" {
205+
seen[revision] = struct{}{}
206+
}
207+
}
208+
return seen
209+
}
210+
165211
// pickWeightedRevision returns one revision from weights, chosen with
166212
// probability weights[r] / Σ weights. Iteration order is stabilised via
167213
// sort so a fixed rand01 gives repeatable output in tests.

pkg/epp/disaggregation/register.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func gatingForLog(g *Gating) string {
9292
// single revision — the filter would otherwise silently drop every
9393
// endpoint at request time.
9494
func validateRolesObserved(ctx context.Context, c *Controller, roles []string) error {
95-
revisions, roleCounts, err := c.scanCoverage(ctx)
95+
revisions, roleCounts, err := c.readyPodsByRevisionRole(ctx)
9696
if err != nil {
9797
return err
9898
}

pkg/epp/disaggregation/wire.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,26 @@ import (
66
)
77

88
// WireInto attaches a Controller to a SchedulerConfig and a request-control
9-
// Config in the correct positions:
9+
// Config. The scheduler filter chain, in execution order per request:
1010
//
11-
// - strict-selector filter prepended to every scheduler profile's filter
12-
// chain, so no downstream filter ever sees a wrong-revision candidate.
13-
// - prefer-selector filter appended to every profile's filter chain, so
14-
// its fallback captures the fully-narrowed pool.
15-
// - gating filter appended after the two selector wrappers (only when the
16-
// config declares an active Gating block), so revisions that fail the
17-
// gating check are dropped before the picker sees them.
18-
// - response-header processor attached to the request-control pipeline.
11+
// gating → strict → operator filters → prefer
1912
//
20-
// No scorer is registered: pick behaviour is left to the operator's YAML
21-
// picker choice. The gating filter handles cross-role liveness; any
22-
// traffic-shaping across surviving revisions is a follow-on concern.
13+
// The revision axis is decided at the head of the chain — either by the
14+
// gating filter (stochastic, cache-driven weighted pick, fires when no
15+
// client header is present) or by the strict filter (deterministic, from
16+
// the request header, fires when the header is set). Only one of the two
17+
// makes the revision decision on any given request; the other detects the
18+
// situation and fast-paths.
19+
//
20+
// Everything downstream of the head therefore sees a candidate pool
21+
// already narrowed to a single revision. Operator-declared filters run in
22+
// the middle. The prefer filter is appended at the tail so that its
23+
// "keep-if-empty" fallback captures the fully-narrowed pool — an operator
24+
// filter that trims after prefer would violate prefer's fallback set.
25+
//
26+
// The response-header processor is attached to the request-control
27+
// pipeline. No scorer is registered: pick behaviour is left to the
28+
// operator's YAML picker choice.
2329
func WireInto(schedulerConfig *scheduling.SchedulerConfig, requestControlConfig *requestcontrol.Config, controller *Controller) error {
2430
if controller == nil {
2531
return nil
@@ -38,15 +44,18 @@ func WireInto(schedulerConfig *scheduling.SchedulerConfig, requestControlConfig
3844
}
3945

4046
for _, profile := range schedulerConfig.Profiles() {
47+
// Prepend order matters: the LAST PrependFilter call ends up at
48+
// position 0 of the chain. Strict is prepended first so gating
49+
// ends up in front of it — gating is the first thing to run.
4150
if hasStrict {
4251
profile.PrependFilter(strictFilter)
4352
}
53+
if gatingActive {
54+
profile.PrependFilter(gating)
55+
}
4456
if hasPrefer {
4557
profile.AppendFilter(preferFilter)
4658
}
47-
if gatingActive {
48-
profile.AppendFilter(gating)
49-
}
5059
}
5160
requestControlConfig.AddPlugins(controller)
5261
return nil

pkg/epp/disaggregation/wire_test.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ func buildSchedulerConfigForTest(t *testing.T) *scheduling.SchedulerConfig {
2323
return scheduling.NewSchedulerConfig(singleprofilehandler.NewSingleProfileHandler(), profiles)
2424
}
2525

26-
// TestWireInto_StrictAtHead_PreferAtTail proves the placement contract: an
27-
// existing operator-declared filter ends up sandwiched between our strict and
28-
// prefer filters.
29-
func TestWireInto_StrictAtHead_PreferAtTail(t *testing.T) {
26+
// TestWireInto_GatingAtHead_PreferAtTail proves the placement contract:
27+
// the revision decision is made at the head (gating first, then strict —
28+
// only one of them fires per request), any operator-declared filters run
29+
// on the resulting single-revision pool, and prefer runs at the tail so
30+
// its keep-if-empty fallback sees the fully-narrowed pool.
31+
func TestWireInto_GatingAtHead_PreferAtTail(t *testing.T) {
3032
schedulerConfig := buildSchedulerConfigForTest(t)
3133
profile := schedulerConfig.Profiles()["default"]
3234
profile.AppendFilter(&namedTestFilter{name: "operator-filter"})
@@ -47,18 +49,18 @@ func TestWireInto_StrictAtHead_PreferAtTail(t *testing.T) {
4749

4850
// Read back the filter chain via profile.String() — the framework does
4951
// not expose the private slice directly. Expected order in the string:
50-
// strict, operator, prefer, gating.
52+
// gating, strict, operator, prefer.
5153
got := profile.String()
54+
gatingAt := indexOf(got, gatingFilterType)
5255
strictAt := indexOf(got, strictFilterType)
5356
operatorAt := indexOf(got, "operator-filter")
5457
preferAt := indexOf(got, preferFilterType)
55-
gatingAt := indexOf(got, gatingFilterType)
56-
if strictAt < 0 || operatorAt < 0 || preferAt < 0 || gatingAt < 0 {
58+
if gatingAt < 0 || strictAt < 0 || operatorAt < 0 || preferAt < 0 {
5759
t.Fatalf("expected all four filters in profile string, got %q", got)
5860
}
59-
if strictAt >= operatorAt || operatorAt >= preferAt || preferAt >= gatingAt {
60-
t.Fatalf("expected order strict < operator < prefer < gating in %q (positions %d, %d, %d, %d)",
61-
got, strictAt, operatorAt, preferAt, gatingAt)
61+
if gatingAt >= strictAt || strictAt >= operatorAt || operatorAt >= preferAt {
62+
t.Fatalf("expected order gating < strict < operator < prefer in %q (positions %d, %d, %d, %d)",
63+
got, gatingAt, strictAt, operatorAt, preferAt)
6264
}
6365
}
6466

0 commit comments

Comments
 (0)