Skip to content

Commit 82ab087

Browse files
committed
Address review: rename to endpoint-attribute-scorer, restructure config with algorithm block and fixedRange/adaptiveRange normalization
Signed-off-by: Alok Behera <alokbeherak061@gmail.com>
1 parent 998cbda commit 82ab087

7 files changed

Lines changed: 614 additions & 425 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ import (
108108
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/profilehandler/single"
109109
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/activerequest"
110110
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/contextlengthaware"
111+
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/endpointattribute"
111112
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/kvcacheutilization"
112113
latencyscorer "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/latency"
113114
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/loadaware"
114115
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/loraaffinity"
115-
metricscorer "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/metric"
116116
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/mmcacheaffinity"
117117
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/nohitlru"
118118
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/preciseprefixcache"
@@ -510,7 +510,7 @@ func (r *Runner) registerInTreePlugins() {
510510
fwkplugin.Register(disagg.PrefixBasedPDDeciderPluginType, disagg.PrefixBasedPDDeciderPluginFactory)
511511
fwkplugin.Register(disagg.AlwaysDisaggMulimodalPluginType, disagg.AlwaysDisaggMulimodalDeciderPluginFactory)
512512
fwkplugin.Register(kvcacheutilization.KvCacheUtilizationScorerType, kvcacheutilization.KvCacheUtilizationScorerFactory)
513-
fwkplugin.Register(metricscorer.MetricScorerType, metricscorer.MetricScorerFactory)
513+
fwkplugin.Register(endpointattribute.EndpointAttributeScorerType, endpointattribute.EndpointAttributeScorerFactory)
514514
fwkplugin.Register(queuedepth.QueueScorerType, queuedepth.QueueScorerFactory)
515515
fwkplugin.Register(runningrequests.RunningRequestsSizeScorerType, runningrequests.RunningRequestsSizeScorerFactory)
516516
fwkplugin.Register(loraaffinity.LoraAffinityScorerType, loraaffinity.LoraAffinityScorerFactory)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Endpoint Attribute Scorer Plugin
2+
3+
**Type:** `endpoint-attribute-scorer`
4+
5+
This plugin scores candidate endpoints by a single configured numeric endpoint attribute.
6+
7+
## What it does
8+
9+
For each scheduling cycle, the plugin reads the configured attribute (`attributeKey`) from each candidate endpoint and computes a linearly normalized score:
10+
11+
\[
12+
\text{score(endpoint)} = \frac{\text{value(endpoint)} - \min}{\max - \min}
13+
\]
14+
15+
With `algorithm.type: linear_lower_is_better` the result is inverted, so the lowest value gets score `1.0` and the highest gets `0.0`. With `linear_higher_is_better` the highest value gets `1.0`.
16+
17+
The `[min, max]` range comes from the configured normalization strategy:
18+
19+
- **`adaptiveRange`** (the default) — the range is computed across the candidate endpoints each scheduling cycle. Suited to open-ended attributes such as queue depth. If all endpoints that have the attribute share the same value, they all receive a neutral score of `1.0`.
20+
- **`fixedRange`** — the range is the configured `min`/`max`. Suited to attributes with known bounds such as kv-cache utilization (`[0, 1]`). Values outside the range are clamped.
21+
22+
In both strategies, an endpoint missing the attribute receives score `0.0` (and, for `adaptiveRange`, does not participate in the range computation).
23+
24+
The attribute is expected to be a numeric custom metric produced by the core metrics extractor (see the [metrics extractor](../../../datalayer/extractor/metrics/README.md)), stored as a `ScalarMetricValue` endpoint attribute.
25+
26+
## Scheduling intent
27+
28+
The scorer returns category `Distribution`, helping spread requests according to the configured attribute.
29+
30+
## Inputs consumed
31+
32+
The plugin consumes:
33+
34+
- the configured `attributeKey` (`ScalarMetricValue`)
35+
36+
## Configuration
37+
38+
| Parameter | Required | Description |
39+
|----------------------------------------------|----------|--------------------------------------------------------------------------------|
40+
| `attributeKey` | yes | Endpoint attribute to read, e.g. `custom.queue_depth`. |
41+
| `algorithm.type` | yes | `linear_lower_is_better` or `linear_higher_is_better`. |
42+
| `algorithm.normalization` | no | At most one strategy: `adaptiveRange` (the default) or `fixedRange`. |
43+
| `algorithm.normalization.fixedRange.min/max` | no | Bounds for `fixedRange` normalization; `min` must be less than `max`. |
44+
45+
**Configuration Example (adaptive range):**
46+
```yaml
47+
plugins:
48+
- type: endpoint-attribute-scorer
49+
name: queue-depth-attribute
50+
parameters:
51+
attributeKey: "custom.queue_depth"
52+
algorithm:
53+
type: "linear_lower_is_better"
54+
schedulingProfiles:
55+
- name: default
56+
plugins:
57+
- pluginRef: queue-depth-attribute
58+
weight: 1
59+
```
60+
61+
**Configuration Example (fixed range):**
62+
```yaml
63+
plugins:
64+
- type: endpoint-attribute-scorer
65+
name: kv-cache-attribute
66+
parameters:
67+
attributeKey: "custom.kv_cache_utilization"
68+
algorithm:
69+
type: "linear_lower_is_better"
70+
normalization:
71+
fixedRange:
72+
min: 0.0
73+
max: 1.0
74+
schedulingProfiles:
75+
- name: default
76+
plugins:
77+
- pluginRef: kv-cache-attribute
78+
weight: 1
79+
```
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package endpointattribute
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"math"
25+
26+
fwkplugin "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
27+
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
28+
attrmetrics "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/metrics"
29+
)
30+
31+
const (
32+
EndpointAttributeScorerType = "endpoint-attribute-scorer"
33+
34+
algorithmLinearLowerIsBetter = "linear_lower_is_better"
35+
algorithmLinearHigherIsBetter = "linear_higher_is_better"
36+
)
37+
38+
// compile-time type assertion
39+
var _ fwksched.Scorer = &EndpointAttributeScorer{}
40+
41+
// fixedRangeParameters normalizes the attribute value against a fixed
42+
// [min, max] range (e.g. kv-cache utilization, which is always in [0, 1]).
43+
type fixedRangeParameters struct {
44+
Min float64 `json:"min"`
45+
Max float64 `json:"max"`
46+
}
47+
48+
// adaptiveRangeParameters normalizes the attribute value against an adaptive
49+
// [min, max] range computed across the candidate endpoints (e.g. queue depth).
50+
type adaptiveRangeParameters struct{}
51+
52+
// normalizationParameters selects the normalization strategy. At most one
53+
// strategy may be set; adaptiveRange is the default when none is set.
54+
type normalizationParameters struct {
55+
FixedRange *fixedRangeParameters `json:"fixedRange,omitempty"`
56+
AdaptiveRange *adaptiveRangeParameters `json:"adaptiveRange,omitempty"`
57+
}
58+
59+
type algorithmParameters struct {
60+
Type string `json:"type"`
61+
Normalization normalizationParameters `json:"normalization"`
62+
}
63+
64+
type parameters struct {
65+
AttributeKey string `json:"attributeKey"`
66+
Algorithm algorithmParameters `json:"algorithm"`
67+
}
68+
69+
// EndpointAttributeScorerFactory defines the factory function for EndpointAttributeScorer.
70+
func EndpointAttributeScorerFactory(name string, decoder *json.Decoder, _ fwkplugin.Handle) (fwkplugin.Plugin, error) {
71+
var params parameters
72+
if decoder != nil {
73+
if err := decoder.Decode(&params); err != nil {
74+
return nil, fmt.Errorf("failed to decode endpoint attribute scorer parameters: %w", err)
75+
}
76+
}
77+
78+
return NewEndpointAttributeScorer(name, params)
79+
}
80+
81+
// NewEndpointAttributeScorer validates the given parameters and returns a new
82+
// EndpointAttributeScorer with the given name.
83+
func NewEndpointAttributeScorer(name string, params parameters) (*EndpointAttributeScorer, error) {
84+
if name == "" {
85+
name = EndpointAttributeScorerType
86+
}
87+
if params.AttributeKey == "" {
88+
return nil, errors.New("endpoint attribute scorer requires a non-empty attributeKey")
89+
}
90+
switch params.Algorithm.Type {
91+
case algorithmLinearLowerIsBetter, algorithmLinearHigherIsBetter:
92+
default:
93+
return nil, fmt.Errorf("endpoint attribute scorer algorithm.type must be %q or %q, got %q",
94+
algorithmLinearLowerIsBetter, algorithmLinearHigherIsBetter, params.Algorithm.Type)
95+
}
96+
97+
normalization := params.Algorithm.Normalization
98+
if normalization.FixedRange != nil && normalization.AdaptiveRange != nil {
99+
return nil, errors.New("endpoint attribute scorer allows at most one normalization strategy, got both fixedRange and adaptiveRange")
100+
}
101+
if fixed := normalization.FixedRange; fixed != nil && fixed.Min >= fixed.Max {
102+
return nil, fmt.Errorf("endpoint attribute scorer normalization.fixedRange requires min < max, got min %v, max %v",
103+
fixed.Min, fixed.Max)
104+
}
105+
106+
return &EndpointAttributeScorer{
107+
typedName: fwkplugin.TypedName{Type: EndpointAttributeScorerType, Name: name},
108+
attributeKey: params.AttributeKey,
109+
lowerIsBetter: params.Algorithm.Type == algorithmLinearLowerIsBetter,
110+
fixedRange: normalization.FixedRange,
111+
}, nil
112+
}
113+
114+
// EndpointAttributeScorer scores candidate endpoints by a single configured
115+
// numeric endpoint attribute (produced by the custom metrics extraction layer),
116+
// linearly normalized against either a fixed [min, max] range or an adaptive
117+
// range computed across the candidates.
118+
type EndpointAttributeScorer struct {
119+
typedName fwkplugin.TypedName
120+
attributeKey string
121+
lowerIsBetter bool
122+
// fixedRange selects fixed-range normalization when set; adaptive-range
123+
// normalization is used otherwise.
124+
fixedRange *fixedRangeParameters
125+
}
126+
127+
// TypedName returns the type and name tuple of this plugin instance.
128+
func (s *EndpointAttributeScorer) TypedName() fwkplugin.TypedName {
129+
return s.typedName
130+
}
131+
132+
// Category returns the preference the scorer applies when scoring candidate endpoints.
133+
func (s *EndpointAttributeScorer) Category() fwksched.ScorerCategory {
134+
return fwksched.Distribution
135+
}
136+
137+
// Consumes returns the list of data that is consumed by the plugin.
138+
func (s *EndpointAttributeScorer) Consumes() map[string]any {
139+
return map[string]any{
140+
s.attributeKey: attrmetrics.ScalarMetricValue(0),
141+
}
142+
}
143+
144+
// Score returns the scoring result for the given list of endpoints based on the
145+
// configured endpoint attribute. Endpoints missing the attribute score 0.
146+
func (s *EndpointAttributeScorer) Score(_ context.Context, _ *fwksched.InferenceRequest, endpoints []fwksched.Endpoint) map[fwksched.Endpoint]float64 {
147+
if s.fixedRange != nil {
148+
return s.scoreFixedRange(endpoints)
149+
}
150+
return s.scoreAdaptiveRange(endpoints)
151+
}
152+
153+
// scoreFixedRange normalizes each endpoint's attribute value against the
154+
// configured [min, max] range, clamping values outside the range.
155+
func (s *EndpointAttributeScorer) scoreFixedRange(endpoints []fwksched.Endpoint) map[fwksched.Endpoint]float64 {
156+
scores := make(map[fwksched.Endpoint]float64, len(endpoints))
157+
for _, endpoint := range endpoints {
158+
value, ok := attrmetrics.ReadScalarMetricValue(endpoint, s.attributeKey)
159+
if !ok {
160+
scores[endpoint] = 0.0
161+
continue
162+
}
163+
normalized := (float64(value) - s.fixedRange.Min) / (s.fixedRange.Max - s.fixedRange.Min)
164+
normalized = math.Max(0.0, math.Min(1.0, normalized))
165+
if s.lowerIsBetter {
166+
normalized = 1.0 - normalized
167+
}
168+
scores[endpoint] = normalized
169+
}
170+
return scores
171+
}
172+
173+
// scoreAdaptiveRange normalizes each endpoint's attribute value against the
174+
// [min, max] range observed across the candidates. Endpoints missing the
175+
// attribute do not participate in the range. If all endpoints that have the
176+
// attribute share the same value, they all receive a neutral score of 1.0.
177+
func (s *EndpointAttributeScorer) scoreAdaptiveRange(endpoints []fwksched.Endpoint) map[fwksched.Endpoint]float64 {
178+
values := make(map[fwksched.Endpoint]float64, len(endpoints))
179+
minValue := math.Inf(1)
180+
maxValue := math.Inf(-1)
181+
182+
for _, endpoint := range endpoints {
183+
value, ok := attrmetrics.ReadScalarMetricValue(endpoint, s.attributeKey)
184+
if !ok {
185+
continue
186+
}
187+
floatValue := float64(value)
188+
values[endpoint] = floatValue
189+
if floatValue < minValue {
190+
minValue = floatValue
191+
}
192+
if floatValue > maxValue {
193+
maxValue = floatValue
194+
}
195+
}
196+
197+
scores := make(map[fwksched.Endpoint]float64, len(endpoints))
198+
for _, endpoint := range endpoints {
199+
value, ok := values[endpoint]
200+
if !ok {
201+
scores[endpoint] = 0.0
202+
continue
203+
}
204+
if maxValue == minValue {
205+
// All endpoints with the attribute have the same value, return a neutral score.
206+
scores[endpoint] = 1.0
207+
continue
208+
}
209+
normalized := (value - minValue) / (maxValue - minValue)
210+
if s.lowerIsBetter {
211+
normalized = 1.0 - normalized
212+
}
213+
scores[endpoint] = normalized
214+
}
215+
return scores
216+
}

0 commit comments

Comments
 (0)