Skip to content

Commit 3b3956e

Browse files
committed
Add generic metric scorer for configured endpoint attributes
Scores candidate endpoints by one configured numeric endpoint attribute produced by the core metrics extractor, with linear normalization across candidates and configurable direction (lower_is_better/higher_is_better). Endpoints sharing one value get a neutral score; endpoints missing the attribute score zero and are excluded from normalization. Signed-off-by: Alok Behera <alokbeherak061@gmail.com>
1 parent 2136215 commit 3b3956e

4 files changed

Lines changed: 425 additions & 0 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import (
112112
latencyscorer "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/latency"
113113
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/loadaware"
114114
"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"
115116
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/mmcacheaffinity"
116117
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/nohitlru"
117118
"github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/scheduling/scorer/preciseprefixcache"
@@ -509,6 +510,7 @@ func (r *Runner) registerInTreePlugins() {
509510
fwkplugin.Register(disagg.PrefixBasedPDDeciderPluginType, disagg.PrefixBasedPDDeciderPluginFactory)
510511
fwkplugin.Register(disagg.AlwaysDisaggMulimodalPluginType, disagg.AlwaysDisaggMulimodalDeciderPluginFactory)
511512
fwkplugin.Register(kvcacheutilization.KvCacheUtilizationScorerType, kvcacheutilization.KvCacheUtilizationScorerFactory)
513+
fwkplugin.Register(metricscorer.MetricScorerType, metricscorer.MetricScorerFactory)
512514
fwkplugin.Register(queuedepth.QueueScorerType, queuedepth.QueueScorerFactory)
513515
fwkplugin.Register(runningrequests.RunningRequestsSizeScorerType, runningrequests.RunningRequestsSizeScorerFactory)
514516
fwkplugin.Register(loraaffinity.LoraAffinityScorerType, loraaffinity.LoraAffinityScorerFactory)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Metric Scorer Plugin
2+
3+
**Type:** `metric-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 across the candidates:
10+
11+
\[
12+
\text{score(endpoint)} = \frac{\text{value(endpoint)} - \min}{\max - \min}
13+
\]
14+
15+
With `direction: lower_is_better` the result is inverted, so the lowest value gets score `1.0` and the highest gets `0.0`.
16+
17+
Special cases:
18+
19+
- If all endpoints that have the attribute share the same value, they all receive a neutral score of `1.0`.
20+
- An endpoint missing the attribute receives score `0.0` and does not participate in normalization.
21+
22+
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.
23+
24+
## Scheduling intent
25+
26+
The scorer returns category `Distribution`, helping spread requests according to the configured metric.
27+
28+
## Inputs consumed
29+
30+
The plugin consumes:
31+
32+
- the configured `attributeKey` (`ScalarMetricValue`)
33+
34+
## Configuration
35+
36+
| Parameter | Required | Description |
37+
|---------------------------|----------|----------------------------------------------------------------------|
38+
| `attributeKey` | yes | Endpoint attribute to read, e.g. `custom.queue_depth`. |
39+
| `normalization.type` | no | Normalization strategy. Only `linear` is supported (the default). |
40+
| `normalization.direction` | yes | `lower_is_better` or `higher_is_better`. |
41+
42+
**Configuration Example:**
43+
```yaml
44+
plugins:
45+
- type: metric-scorer
46+
name: queue-depth-metric
47+
parameters:
48+
attributeKey: "custom.queue_depth"
49+
normalization:
50+
type: "linear"
51+
direction: "lower_is_better"
52+
schedulingProfiles:
53+
- name: default
54+
plugins:
55+
- pluginRef: queue-depth-metric
56+
weight: 1
57+
```
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
Copyright 2025 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 metric
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+
MetricScorerType = "metric-scorer"
33+
34+
normalizationLinear = "linear"
35+
36+
directionLowerIsBetter = "lower_is_better"
37+
directionHigherIsBetter = "higher_is_better"
38+
)
39+
40+
// compile-time type assertion
41+
var _ fwksched.Scorer = &MetricScorer{}
42+
43+
type normalizationParameters struct {
44+
Type string `json:"type"`
45+
Direction string `json:"direction"`
46+
}
47+
48+
type parameters struct {
49+
AttributeKey string `json:"attributeKey"`
50+
Normalization normalizationParameters `json:"normalization"`
51+
}
52+
53+
// MetricScorerFactory defines the factory function for MetricScorer.
54+
func MetricScorerFactory(name string, decoder *json.Decoder, _ fwkplugin.Handle) (fwkplugin.Plugin, error) {
55+
var params parameters
56+
if decoder != nil {
57+
if err := decoder.Decode(&params); err != nil {
58+
return nil, fmt.Errorf("failed to decode metric scorer parameters: %w", err)
59+
}
60+
}
61+
62+
scorer, err := NewMetricScorer(params)
63+
if err != nil {
64+
return nil, err
65+
}
66+
return scorer.WithName(name), nil
67+
}
68+
69+
// NewMetricScorer validates the given parameters and returns a new MetricScorer.
70+
func NewMetricScorer(params parameters) (*MetricScorer, error) {
71+
if params.AttributeKey == "" {
72+
return nil, errors.New("metric scorer requires a non-empty attributeKey")
73+
}
74+
if params.Normalization.Type == "" {
75+
params.Normalization.Type = normalizationLinear
76+
}
77+
if params.Normalization.Type != normalizationLinear {
78+
return nil, fmt.Errorf("metric scorer normalization.type must be %q, got %q",
79+
normalizationLinear, params.Normalization.Type)
80+
}
81+
switch params.Normalization.Direction {
82+
case directionLowerIsBetter, directionHigherIsBetter:
83+
default:
84+
return nil, fmt.Errorf("metric scorer normalization.direction must be %q or %q, got %q",
85+
directionLowerIsBetter, directionHigherIsBetter, params.Normalization.Direction)
86+
}
87+
88+
return &MetricScorer{
89+
typedName: fwkplugin.TypedName{Type: MetricScorerType, Name: MetricScorerType},
90+
attributeKey: params.AttributeKey,
91+
lowerIsBetter: params.Normalization.Direction == directionLowerIsBetter,
92+
}, nil
93+
}
94+
95+
// MetricScorer scores candidate endpoints by a single configured numeric endpoint
96+
// attribute, linearly normalized across the candidates. The attribute is produced
97+
// by the core metrics extractor from a configured model-server metric.
98+
type MetricScorer struct {
99+
typedName fwkplugin.TypedName
100+
attributeKey string
101+
lowerIsBetter bool
102+
}
103+
104+
// TypedName returns the type and name tuple of this plugin instance.
105+
func (s *MetricScorer) TypedName() fwkplugin.TypedName {
106+
return s.typedName
107+
}
108+
109+
// Category returns the preference the scorer applies when scoring candidate endpoints.
110+
func (s *MetricScorer) Category() fwksched.ScorerCategory {
111+
return fwksched.Distribution
112+
}
113+
114+
// Consumes returns the list of data that is consumed by the plugin.
115+
func (s *MetricScorer) Consumes() map[string]any {
116+
return map[string]any{
117+
s.attributeKey: attrmetrics.ScalarMetricValue(0),
118+
}
119+
}
120+
121+
// WithName sets the name of the scorer.
122+
func (s *MetricScorer) WithName(name string) *MetricScorer {
123+
s.typedName.Name = name
124+
return s
125+
}
126+
127+
// Score returns the scoring result for the given list of endpoints based on the
128+
// configured endpoint attribute. Endpoints missing the attribute score 0 and do
129+
// not participate in normalization. If all endpoints that have the attribute
130+
// share the same value, they all receive a neutral score of 1.0.
131+
func (s *MetricScorer) Score(_ context.Context, _ *fwksched.InferenceRequest, endpoints []fwksched.Endpoint) map[fwksched.Endpoint]float64 {
132+
values := make(map[fwksched.Endpoint]float64, len(endpoints))
133+
minValue := math.Inf(1)
134+
maxValue := math.Inf(-1)
135+
136+
for _, endpoint := range endpoints {
137+
value, ok := attrmetrics.ReadScalarMetricValue(endpoint, s.attributeKey)
138+
if !ok {
139+
continue
140+
}
141+
floatValue := float64(value)
142+
values[endpoint] = floatValue
143+
if floatValue < minValue {
144+
minValue = floatValue
145+
}
146+
if floatValue > maxValue {
147+
maxValue = floatValue
148+
}
149+
}
150+
151+
scores := make(map[fwksched.Endpoint]float64, len(endpoints))
152+
for _, endpoint := range endpoints {
153+
value, ok := values[endpoint]
154+
if !ok {
155+
scores[endpoint] = 0.0
156+
continue
157+
}
158+
if maxValue == minValue {
159+
// All endpoints with the attribute have the same value, return a neutral score.
160+
scores[endpoint] = 1.0
161+
continue
162+
}
163+
normalized := (value - minValue) / (maxValue - minValue)
164+
if s.lowerIsBetter {
165+
normalized = 1.0 - normalized
166+
}
167+
scores[endpoint] = normalized
168+
}
169+
return scores
170+
}

0 commit comments

Comments
 (0)