Skip to content

Commit 632e95d

Browse files
committed
Add native disaggregation controller
Introduces pkg/epp/disaggregation: a single Controller struct that owns label-aware routing across the roles of a disaggregated inference deployment (e.g. prefill / decode). Implements scheduling.Scorer and requestcontrol.ResponseHeaderProcessor directly; Filter is done via two thin wrappers installed by WireInto so strict-mode selectors run at the head of the profile's filter chain and prefer-mode selectors at the tail. Config surface — one top-level disaggregation: YAML block: ```yaml disaggregation: enabled: true scope: labelSelector: "disaggregatedset.x-k8s.io/name=my-set" selectors: - name: revision headerName: x-disagg-revision labelKey: disaggregatedset.x-k8s.io/revision mode: strict # strict | prefer scoring: mode: sum # sum | disabled requireRoles: # required when mode: sum labelKey: disaggregatedset.x-k8s.io/role values: [prefill, decode] ``` Nearly the same file works on every role's EPP; only scoring.mode differs — sum on the originating side (prefill), disabled on the terminal side (decode) where the filter has already pinned traffic to a single revision and per-pod scoring adds nothing. Why strict revision routing matters (not just a nice-to-have): * disaggregated inference technically routes fine without any of this — DisaggregatedSet's placement policy runs at a layer below routing, and you could argue routing across revisions during a rolling update is harmless. In production it isn't. When rolling from revision A to B, you run into two failure classes: * Revision B not backward-compatible with A — either new-revision pods die outright when they receive a request meant for the old shape, or they fail gracefully and the retry also fails, dropping requests and tanking QoS. * Corrupted KVCache on revision A — vLLM produced a bad KV cache on some requests on a bad version that had a bug, and you do not want that cache carried into the newer revision's pods. Both are cases where cross-revision routing during rollout is actively harmful, which is why the revision selector runs in strict mode. I have other examples, like when vLLM init past prefill and keep it in its memory, but this is got solved. At the NVL72 level (stride) IB connections still exist between NVL72 racks, so prefer-mode is the right knob for that selector. Signed-off-by: Mathis Felardos <mathis@mistral.ai>
1 parent 55d381f commit 632e95d

19 files changed

Lines changed: 2176 additions & 0 deletions

apix/config/v1alpha1/endpointpickerconfig_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ type EndpointPickerConfig struct {
5858
// This configuration is only respected if the "flowControl" FeatureGate is enabled.
5959
FlowControl *FlowControlConfig `json:"flowControl,omitempty"`
6060

61+
// +optional
62+
// Disaggregation configures native, label-aware routing across roles of a
63+
// disaggregated inference deployment (e.g. prefill / decode). Parsed by
64+
// pkg/epp/disaggregation.Register at boot; opaque here to avoid coupling
65+
// the schema to the implementation.
66+
Disaggregation *json.RawMessage `json:"disaggregation,omitempty"`
67+
6168
// +optional
6269
// RequestHandler specifies the handling logic used by the EPP to process incoming requests.
6370
RequestHandler *RequestHandlerConfig `json:"requestHandler,omitempty"`

apix/config/v1alpha1/zz_generated.deepcopy.go

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/epp/runner/disaggregation.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2025 The Kubernetes Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
package runner
10+
11+
import (
12+
"context"
13+
"encoding/json"
14+
"fmt"
15+
16+
"k8s.io/client-go/kubernetes"
17+
"k8s.io/client-go/rest"
18+
19+
"github.com/llm-d/llm-d-router/pkg/epp/disaggregation"
20+
)
21+
22+
// registerDisaggregation instantiates the disaggregation controller if the
23+
// rawConfig contains a disaggregation block and hands it to WireInto, which
24+
// does the correct-position registration in the scheduler and request-control
25+
// pipelines. No-op when the block is absent.
26+
func (r *Runner) registerDisaggregation(ctx context.Context, restCfg *rest.Config, namespace string) error {
27+
if r.rawConfig == nil || r.rawConfig.Disaggregation == nil {
28+
return nil
29+
}
30+
31+
var config disaggregation.Config
32+
if err := json.Unmarshal(*r.rawConfig.Disaggregation, &config); err != nil {
33+
return fmt.Errorf("parse disaggregation config: %w", err)
34+
}
35+
if !config.Enabled {
36+
return nil
37+
}
38+
39+
client, err := kubernetes.NewForConfig(restCfg)
40+
if err != nil {
41+
return fmt.Errorf("build kubernetes client for disaggregation: %w", err)
42+
}
43+
44+
controller, err := disaggregation.Register(ctx, client, namespace, config)
45+
if err != nil {
46+
return fmt.Errorf("register disaggregation controller: %w", err)
47+
}
48+
if controller == nil {
49+
return nil
50+
}
51+
return disaggregation.WireInto(r.schedulerConfig, r.requestControlConfig, controller)
52+
}

cmd/epp/runner/runner.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,11 @@ func (r *Runner) setup(ctx context.Context, cfg *rest.Config, opts *runserver.Op
439439

440440
setupLog.Info("parsed config", "scheduler-config", r.schedulerConfig)
441441

442+
if err := r.registerDisaggregation(ctx, cfg, gknn.Namespace); err != nil {
443+
setupLog.Error(err, "failed to register disaggregation controller")
444+
return nil, nil, err
445+
}
446+
442447
scheduler := scheduling.NewSchedulerWithConfig(r.schedulerConfig)
443448

444449
if err := r.configureAndStartDatalayer(ctx, eppConfig.DataConfig, mgr); err != nil {

pkg/epp/disaggregation/config.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Package disaggregation implements native, label-aware routing across roles
2+
// of a disaggregated inference deployment (e.g. prefill/decode).
3+
//
4+
// A single Controller implements scheduling.Scorer and
5+
// requestcontrol.ResponseHeaderProcessor. Filter is handled by two thin
6+
// wrappers built by WireInto: strict-mode selectors run at the head of the
7+
// scheduler profile's filter chain, prefer-mode selectors at the tail.
8+
//
9+
// Boot flow: Register (validate config, start pod cache, verify config
10+
// against observed cluster state) then WireInto (attach controller to
11+
// scheduler profiles and request-control pipeline). Both are called from
12+
// cmd/epp/runner/disaggregation.go.
13+
package disaggregation
14+
15+
import (
16+
"encoding/json"
17+
"fmt"
18+
"strings"
19+
20+
"k8s.io/apimachinery/pkg/labels"
21+
)
22+
23+
// Config is the top-level disaggregation block loaded from the EPP YAML.
24+
type Config struct {
25+
Enabled bool `json:"enabled"`
26+
Scope Scope `json:"scope"`
27+
Selectors []Selector `json:"selectors"`
28+
Scoring Scoring `json:"scoring"`
29+
}
30+
31+
// Scope constrains which pods the informer observes.
32+
type Scope struct {
33+
LabelSelector string `json:"labelSelector"`
34+
// Namespace defaults to the InferencePool's namespace when empty.
35+
Namespace string `json:"namespace,omitempty"`
36+
}
37+
38+
// Selector defines one header/label pair to filter and tag on.
39+
type Selector struct {
40+
Name string `json:"name"`
41+
HeaderName string `json:"headerName"`
42+
LabelKey string `json:"labelKey"`
43+
Mode SelectorMode `json:"mode"`
44+
}
45+
46+
// UnmarshalJSON normalises HeaderName to lowercase at parse time. Downstream
47+
// code compares against `request.Headers[headerName]` which are lowercased
48+
// by the framework; keeping normalisation at the boundary means callers of
49+
// Validate see a pure check with no side effects.
50+
func (s *Selector) UnmarshalJSON(data []byte) error {
51+
// Alias avoids infinite recursion into this method.
52+
type raw Selector
53+
var parsed raw
54+
if err := json.Unmarshal(data, &parsed); err != nil {
55+
return err
56+
}
57+
parsed.HeaderName = strings.ToLower(parsed.HeaderName)
58+
*s = Selector(parsed)
59+
return nil
60+
}
61+
62+
// SelectorMode governs filter behaviour when the header carries a value with no
63+
// matching candidates.
64+
type SelectorMode string
65+
66+
const (
67+
// ModeStrict: no match → empty candidate set → framework returns 503.
68+
ModeStrict SelectorMode = "strict"
69+
// ModePrefer: no match → fall back to the unfiltered candidate set.
70+
// (Escape-hatch semantics, not a spectrum; when at least one candidate
71+
// matches, ModePrefer behaves identically to ModeStrict.)
72+
ModePrefer SelectorMode = "prefer"
73+
)
74+
75+
// Scoring picks the weighting strategy applied to candidates that survived filtering.
76+
type Scoring struct {
77+
Mode ScoringMode `json:"mode"`
78+
RequireRoles *RequireRoles `json:"requireRoles,omitempty"`
79+
}
80+
81+
// ScoringMode selects the weighting strategy.
82+
type ScoringMode string
83+
84+
const (
85+
// ScoringModeSum weights each candidate uniformly (1.0 per pod), with the
86+
// requireRoles gate excluding revisions where any listed role has zero
87+
// Ready pods across scope. Paired with weighted-random-picker this gives
88+
// pod-count-proportional traffic over the surviving revisions.
89+
ScoringModeSum ScoringMode = "sum"
90+
// ScoringModeDisabled skips the scorer entirely — the controller does not
91+
// register as a Scorer at boot.
92+
ScoringModeDisabled ScoringMode = "disabled"
93+
)
94+
95+
// RequireRoles gates revisions on cross-role liveness: a revision is eligible for
96+
// scoring only when every listed role has at least one Ready pod for that revision.
97+
type RequireRoles struct {
98+
LabelKey string `json:"labelKey"`
99+
Values []string `json:"values"`
100+
}
101+
102+
// Validate performs static config checks. A disabled config validates
103+
// trivially. All returned errors carry the offending field path so operators
104+
// can locate the problem quickly. Pure — no side effects; header
105+
// normalisation happens at JSON unmarshal time via Selector.UnmarshalJSON.
106+
func (c *Config) Validate() error {
107+
if !c.Enabled {
108+
return nil
109+
}
110+
111+
if c.Scope.LabelSelector == "" {
112+
return fmt.Errorf("disaggregation.scope.labelSelector is required when enabled")
113+
}
114+
if _, err := labels.Parse(c.Scope.LabelSelector); err != nil {
115+
return fmt.Errorf("disaggregation.scope.labelSelector is not a valid label selector: %w", err)
116+
}
117+
118+
if len(c.Selectors) == 0 {
119+
return fmt.Errorf("disaggregation.selectors must contain at least one entry")
120+
}
121+
seenNames := make(map[string]struct{}, len(c.Selectors))
122+
seenHeaders := make(map[string]struct{}, len(c.Selectors))
123+
for index := range c.Selectors {
124+
selector := &c.Selectors[index]
125+
if selector.Name == "" {
126+
return fmt.Errorf("disaggregation.selectors[%d].name is required", index)
127+
}
128+
if selector.HeaderName == "" {
129+
return fmt.Errorf("disaggregation.selectors[%d].headerName is required", index)
130+
}
131+
if selector.LabelKey == "" {
132+
return fmt.Errorf("disaggregation.selectors[%d].labelKey is required", index)
133+
}
134+
switch selector.Mode {
135+
case ModeStrict, ModePrefer:
136+
default:
137+
return fmt.Errorf("disaggregation.selectors[%d].mode %q must be one of strict|prefer", index, selector.Mode)
138+
}
139+
140+
if _, duplicate := seenNames[selector.Name]; duplicate {
141+
return fmt.Errorf("disaggregation.selectors: duplicate selector name %q", selector.Name)
142+
}
143+
seenNames[selector.Name] = struct{}{}
144+
145+
if _, duplicate := seenHeaders[selector.HeaderName]; duplicate {
146+
return fmt.Errorf("disaggregation.selectors: duplicate header name %q", selector.HeaderName)
147+
}
148+
seenHeaders[selector.HeaderName] = struct{}{}
149+
}
150+
151+
switch c.Scoring.Mode {
152+
case ScoringModeSum:
153+
if c.Scoring.RequireRoles == nil {
154+
return fmt.Errorf("disaggregation.scoring.requireRoles is required when scoring.mode=sum")
155+
}
156+
if c.Scoring.RequireRoles.LabelKey == "" {
157+
return fmt.Errorf("disaggregation.scoring.requireRoles.labelKey is required")
158+
}
159+
if len(c.Scoring.RequireRoles.Values) == 0 {
160+
return fmt.Errorf("disaggregation.scoring.requireRoles.values must contain at least one role")
161+
}
162+
case ScoringModeDisabled:
163+
default:
164+
return fmt.Errorf("disaggregation.scoring.mode %q must be one of sum|disabled", c.Scoring.Mode)
165+
}
166+
167+
return nil
168+
}
169+
170+
// HasSelectorsInMode reports whether any selector in this config runs in the
171+
// given mode. Used by wiring to skip registration of a strict/prefer wrapper
172+
// that would have nothing to do.
173+
func (c *Config) HasSelectorsInMode(mode SelectorMode) bool {
174+
for _, selector := range c.Selectors {
175+
if selector.Mode == mode {
176+
return true
177+
}
178+
}
179+
return false
180+
}

0 commit comments

Comments
 (0)