Skip to content

Commit bf25381

Browse files
committed
Add default profile fallback and single-profile shortcut
Signed-off-by: roytman <roytman@il.ibm.com>
1 parent 28b3634 commit bf25381

3 files changed

Lines changed: 247 additions & 65 deletions

File tree

pkg/epp/framework/plugins/scheduling/profilehandler/headerphase/README.md

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,25 @@ each request is for, instead of needing one EPP instance per phase.
1212
Reads the configured header from the incoming request and looks up the
1313
`schedulingProfiles` entry with that exact name:
1414

15-
- If a matching profile hasn't run yet, it runs that profile alone.
16-
- If the header is missing or names a profile that isn't configured, no profile runs. The
17-
scheduler reports that no profile could be run at all, without a reason, which the EPP
18-
maps to a 429 response to the client -- misleading, since the scheduler doesn't
19-
distinguish a malformed request from exhausted capacity. The EPP logs the specific
20-
reason (missing header vs. unconfigured value) for operators.
15+
- With exactly one profile configured, that profile always runs, regardless of the
16+
header (or its absence). There is nothing else to disaggregate to, so a deployment
17+
scaled down to a single stage -- or one that never disaggregates at all -- works
18+
without swapping to a different profile handler.
19+
- With more than one profile configured and a matching profile named by the header, it
20+
runs that profile alone.
21+
- With more than one profile configured and the header missing or blank, `defaultProfile`
22+
runs instead of failing. This covers calls that never carry the header at all, such as
23+
pass-through requests (e.g. `/models`) that don't go through phase-tagged scheduling.
24+
- With more than one profile configured and the header naming a profile that isn't
25+
configured, no profile runs -- an unrecognized value is a real error, not treated the
26+
same as an absent header. The scheduler reports that no profile could be run at all,
27+
without a reason, which the EPP maps to a 429 response to the client -- misleading,
28+
since the scheduler doesn't distinguish a malformed request from exhausted capacity.
29+
The EPP logs the specific reason (missing header vs. unconfigured value) for operators.
30+
A 400 would be the accurate status here, but `Pick` has no way to carry a typed error
31+
through the scheduler to the client; that needs the same kind of `ProfileHandler`
32+
extension-point signature change tracked for the conditional-decode gate in
33+
[llm-d/llm-d-router#1686](https://github.com/llm-d/llm-d-router/issues/1686).
2134

2235
## How this differs from disagg-profile-handler
2336

@@ -44,6 +57,7 @@ answer and makes one separate scheduling call per phase.
4457
| Name | Type | Default | Description |
4558
|---|---|---|---|
4659
| `headerName` | string | `EPP-Phase` | Request header whose value names the scheduling profile to run. Matched case-insensitively: the EPP lowercases every incoming header name, so this is normalized to lowercase regardless of how it's written here. |
60+
| `defaultProfile` | string | `decode` | Scheduling profile to run when the header is missing or blank and more than one profile is configured. Matched case-sensitively against `schedulingProfiles` names, like the header value itself. Ignored when only one profile is configured, since that profile always runs. |
4761

4862
### Example
4963

@@ -65,4 +79,13 @@ schedulingProfiles:
6579
- pluginRef: decode-filter
6680
```
6781
68-
A request with `EPP-Phase: prefill` runs only the `prefill` profile.
82+
A request with `EPP-Phase: prefill` runs only the `prefill` profile. A request with no
83+
`EPP-Phase` header at all -- e.g. `GET /models` -- runs `decode`, the default.
84+
85+
To use a different fallback than `decode`:
86+
87+
```yaml
88+
- type: header-phase-profile-handler
89+
parameters:
90+
defaultProfile: prefill
91+
```

pkg/epp/framework/plugins/scheduling/profilehandler/headerphase/header_phase_profile_handler.go

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const (
3434

3535
// defaultHeaderName is the request header read when parameters.HeaderName is empty.
3636
defaultHeaderName = "EPP-Phase"
37+
38+
// defaultProfileName is the scheduling profile run when parameters.DefaultProfile is
39+
// empty.
40+
defaultProfileName = "decode"
3741
)
3842

3943
// compile-time type assertion
@@ -44,6 +48,11 @@ type parameters struct {
4448
// HeaderName is the request header whose value names the scheduling profile to run.
4549
// Defaults to defaultHeaderName when empty.
4650
HeaderName string `json:"headerName"`
51+
// DefaultProfile is the scheduling profile to run when the header is missing or
52+
// blank. Defaults to defaultProfileName when empty. Useful for requests that never
53+
// carry the header at all - pass-through calls (e.g. /models) or a deployment that
54+
// doesn't disaggregate.
55+
DefaultProfile string `json:"defaultProfile"`
4756
}
4857

4958
// Factory defines the factory function for HeaderPhaseProfileHandler.
@@ -55,24 +64,35 @@ func Factory(name string, rawParameters *json.Decoder, _ fwkplugin.Handle) (fwkp
5564
}
5665
}
5766

58-
return NewHeaderPhaseProfileHandler(params.HeaderName).WithName(name), nil
67+
return NewHeaderPhaseProfileHandler(params.HeaderName, params.DefaultProfile).WithName(name), nil
5968
}
6069

6170
// NewHeaderPhaseProfileHandler initializes a new HeaderPhaseProfileHandler and returns
62-
// its pointer. headerName is lowercased and trimmed, falling back to defaultHeaderName
63-
// when that leaves it empty: the EPP's request handler lowercases every incoming header
64-
// name at ingestion (pkg/epp/handlers/request.go), so the configured name must be
65-
// normalized the same way to match, and an empty header key would never match any
66-
// request.
67-
func NewHeaderPhaseProfileHandler(headerName string) *HeaderPhaseProfileHandler {
71+
// its pointer.
72+
//
73+
// headerName is lowercased and trimmed, falling back to defaultHeaderName when that
74+
// leaves it empty: the EPP's request handler lowercases every incoming header name at
75+
// ingestion (pkg/epp/handlers/request.go), so the configured name must be normalized the
76+
// same way to match, and an empty header key would never match any request.
77+
//
78+
// defaultProfile is trimmed, falling back to defaultProfileName when that leaves it
79+
// empty. Unlike headerName, it is not case-normalized: it names a schedulingProfiles
80+
// entry, which (like the header value itself) is matched case-sensitively.
81+
func NewHeaderPhaseProfileHandler(headerName, defaultProfile string) *HeaderPhaseProfileHandler {
6882
headerName = strings.ToLower(strings.TrimSpace(headerName))
6983
if headerName == "" {
7084
headerName = strings.ToLower(defaultHeaderName)
7185
}
7286

87+
defaultProfile = strings.TrimSpace(defaultProfile)
88+
if defaultProfile == "" {
89+
defaultProfile = defaultProfileName
90+
}
91+
7392
return &HeaderPhaseProfileHandler{
74-
typedName: fwkplugin.TypedName{Type: HeaderPhaseProfileHandlerType, Name: HeaderPhaseProfileHandlerType},
75-
headerName: headerName,
93+
typedName: fwkplugin.TypedName{Type: HeaderPhaseProfileHandlerType, Name: HeaderPhaseProfileHandlerType},
94+
headerName: headerName,
95+
defaultProfile: defaultProfile,
7696
}
7797
}
7898

@@ -81,9 +101,17 @@ func NewHeaderPhaseProfileHandler(headerName string) *HeaderPhaseProfileHandler
81101
// phases of a disaggregated pipeline (e.g. encode, prefill, decode) whose caller already
82102
// knows, out of band, which phase each request is for - unlike the disagg profile
83103
// handler, which decides which profiles to run via decider plugins.
104+
//
105+
// Two fallbacks keep single-stage and header-less traffic working without a different
106+
// profile handler: with exactly one configured profile there is nothing to disaggregate,
107+
// so that profile always runs regardless of the header (or its absence); with more than
108+
// one configured profile, a request whose header is missing or blank runs defaultProfile
109+
// instead of failing. A header naming a profile that isn't configured is still an error -
110+
// only the header's absence triggers the default, not an unrecognized value.
84111
type HeaderPhaseProfileHandler struct {
85-
typedName fwkplugin.TypedName
86-
headerName string
112+
typedName fwkplugin.TypedName
113+
headerName string
114+
defaultProfile string
87115
}
88116

89117
// TypedName returns the type and name tuple of this plugin instance.
@@ -116,44 +144,55 @@ func (h *HeaderPhaseProfileHandler) noMatchError(phase string) error {
116144
return fmt.Errorf("header-phase profile handler: no scheduling profile configured for %q header value %q", h.headerName, phase)
117145
}
118146

119-
// Pick selects the single SchedulingProfile named by the request's phase header. It
120-
// returns an empty map once that profile has run, or when the header is missing or
121-
// names a profile that isn't configured. In the latter case the scheduler's run loop
122-
// (pkg/epp/scheduling.Scheduler.Schedule) stops without ever calling ProcessResults, so
123-
// the specific reason is logged here rather than returned from ProcessResults, where it
124-
// would be unreachable. The client never sees that reason: it only gets the scheduler's
125-
// generic "failed to run any scheduler profile" error, which
126-
// pkg/epp/requestcontrol/director.go maps to a 429 ResourceExhausted response -
127-
// misleading, since a malformed or missing header is a client error, not a capacity
128-
// problem. Surfacing the real reason to the client needs a scheduler/ProfileHandler
129-
// contract change and is out of scope here; the log is a diagnostic aid for operators,
130-
// not an equivalent substitute for what the caller receives.
147+
// Pick selects the single SchedulingProfile to run: the only configured profile when
148+
// there is just one, otherwise the one named by the request's phase header, falling
149+
// back to defaultProfile when the header is missing or blank. It returns an empty map
150+
// once that profile has run, or when no profile could be resolved. In the latter case
151+
// the scheduler's run loop (pkg/epp/scheduling.Scheduler.Schedule) stops without ever
152+
// calling ProcessResults, so the specific reason is logged here rather than returned
153+
// from ProcessResults, where it would be unreachable. The client never sees that
154+
// reason: it only gets the scheduler's generic "failed to run any scheduler profile"
155+
// error, which pkg/epp/requestcontrol/director.go maps to a 429 ResourceExhausted
156+
// response - misleading, since a malformed or missing header is a client error, not a
157+
// capacity problem. Surfacing the real reason to the client needs a
158+
// scheduler/ProfileHandler contract change and is out of scope here; the log is a
159+
// diagnostic aid for operators, not an equivalent substitute for what the caller
160+
// receives.
131161
func (h *HeaderPhaseProfileHandler) Pick(ctx context.Context, request *fwksched.InferenceRequest, profiles map[string]fwksched.SchedulerProfile,
132162
profileResults map[string]*fwksched.ProfileRunResult) map[string]fwksched.SchedulerProfile {
133-
// TODO(#2135): single profile per request; extend to loop over an ordered phase
134-
// list parsed from the header for non-deferred multi-profile scheduling.
135163
if len(profileResults) > 0 { // the selected profile has already run
136164
return map[string]fwksched.SchedulerProfile{}
137165
}
138166

167+
// With exactly one configured profile there is nothing to disaggregate: always run
168+
// it, so a deployment scaled down to a single stage works without swapping profile
169+
// handlers or requiring every caller to send the header.
170+
if len(profiles) == 1 {
171+
for name, profile := range profiles {
172+
return map[string]fwksched.SchedulerProfile{name: profile}
173+
}
174+
}
175+
139176
phase := h.phaseHeader(request)
140-
profile, ok := profiles[phase]
177+
resolvedPhase := phase
178+
if resolvedPhase == "" {
179+
resolvedPhase = h.defaultProfile
180+
}
181+
182+
profile, ok := profiles[resolvedPhase]
141183
if !ok {
142184
log.FromContext(ctx).Error(h.noMatchError(phase), "no scheduling profile selected for request")
143185
return map[string]fwksched.SchedulerProfile{}
144186
}
145187

146-
return map[string]fwksched.SchedulerProfile{phase: profile}
188+
return map[string]fwksched.SchedulerProfile{resolvedPhase: profile}
147189
}
148190

149191
// ProcessResults handles the outcome of the single profile run selected by Pick.
150192
// It specifies in the SchedulingResult the key of the primary profile that should be
151193
// used to get the request's selected destination.
152194
func (h *HeaderPhaseProfileHandler) ProcessResults(_ context.Context, request *fwksched.InferenceRequest,
153195
profileResults map[string]*fwksched.ProfileRunResult) (*fwksched.SchedulingResult, error) {
154-
// TODO(#2135): single profile per request; extend by re-parsing the header's
155-
// ordered phase list to pick a primary among multiple results for non-deferred
156-
// multi-profile scheduling.
157196
switch len(profileResults) {
158197
case 0:
159198
return nil, h.noMatchError(h.phaseHeader(request))

0 commit comments

Comments
 (0)