Skip to content

Commit 9914597

Browse files
committed
Add support for length-based scoring in prefix cache plugin
Signed-off-by: Azam Ikram <azamikram@google.com>
1 parent 54010b3 commit 9914597

3 files changed

Lines changed: 196 additions & 21 deletions

File tree

pkg/epp/framework/plugins/scheduling/scorer/prefix/README.md

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,59 @@
22

33
**Type:** `prefix-cache-scorer`
44

5-
Scores candidate endpoints using `PrefixCacheMatchInfo` prepared earlier in the request pipeline.
5+
Scores candidate endpoints based on how much of the request's prefix is already cached. It can consider both the *ratio* of the prefix matched and the *absolute length* of the match. By default it only considers the ratio of prefix matched.
66

77
## What it does
88

9-
For each candidate endpoint, the scorer reads the `PrefixCacheMatchInfo` attribute and computes:
9+
The scorer computes a score between `0.0` and `1.0` for each endpoint using a weighted combination of two metrics:
10+
11+
1. **Match Ratio**: The proportion of the request prefix that is cached.
12+
2. **Match Length**: The absolute number of cached tokens, normalized against a scale factor.
13+
14+
### Scoring Formula
1015

1116
```text
12-
score = matchBlocks / totalBlocks
17+
score = matchLengthWeight*matchLengthScore + (1.0-matchLengthWeight)*matchRatioScore
1318
```
1419

15-
This produces a normalized score in the range `[0, 1]`:
20+
Where:
21+
* **`matchRatioScore`** = `matchBlocks` / `totalBlocks`
22+
* **`matchLengthScore`** = `min(1.0, matchedTokens / matchLengthScaleTokens) ^ 2`
23+
* `matchedTokens` = `matchBlocks` * `blockSize`
24+
* **`matchLengthScaleTokens`** is the scaling factor.
1625

17-
- higher score: more of the request prefix is expected to be reusable from cache
18-
- lower score: less prefix cache reuse is expected
26+
If `matchLengthWeight` is `0.0` (default), the score simplifies to just the `matchRatioScore`.
1927

2028
If the attribute is missing, has the wrong type, or `totalBlocks` is zero, the endpoint receives score `0`.
2129

22-
## Inputs consumed
30+
### Why the Quadratic Term?
31+
32+
The squaring of `matchLengthScore` introduces a non-linearity motivated by the fact that attention computation grows quadratically as a function of prompt length. This ensures that the scoring function assigns a higher priority to longer requests, where the computational and latency savings of KV-cache reuse are significantly more critical.
2333

24-
This scorer consumes:
34+
## Inputs Consumed
2535

2636
- `PrefixCacheMatchInfo`
2737

2838
The attribute is typically produced by the approximate prefix cache data producer before scheduling.
2939

3040
## Configuration
3141

32-
This plugin does not define any plugin-specific parameters.
42+
| Parameter | Type | Description | Default |
43+
| :--- | :--- | :--- | :--- |
44+
| `matchLengthWeight` | float | Weight of the absolute match length in the score. Must be between `0.0` and `1.0`. | `0.0` |
45+
| `matchLengthScaleTokens` | integer | The number of tokens used to normalize `matchLengthScore`. | `8192` |
46+
47+
### Example
48+
49+
```yaml
50+
plugins:
51+
- type: prefix-cache-scorer
52+
parameters:
53+
matchLengthWeight: 0.5
54+
matchLengthScaleTokens: 16384
55+
```
3356
34-
## Operational notes
57+
## Operational Notes
3558
36-
- The scorer itself does not hash prompts or maintain cache state.
37-
- It only converts previously prepared prefix match data into endpoint scores.
38-
- To be useful, it should be used together with a data producer that populates `PrefixCacheMatchInfo`.
59+
* **Tuning `matchLengthScaleTokens`**: This should be set to reflect your workload. A good rule of thumb is to set it to the **P95 prompt length** of your typical requests. Setting it too high (e.g., to the model's maximum limit when actual requests are short) will compress the score range and make the scorer less effective.
60+
* The scorer is stateless; it does not manage cache state or hash prompts itself. It relies entirely on the data producer.

pkg/epp/framework/plugins/scheduling/scorer/prefix/plugin.go

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"math"
2324

2425
"sigs.k8s.io/controller-runtime/pkg/log"
2526

@@ -33,12 +34,19 @@ import (
3334
type Config struct {
3435
// The name of the data producer that produces PrefixCacheMatchInfo.
3536
PrefixMatchInfoProducerName string `json:"prefixMatchInfoProducerName,omitempty"`
37+
// The weight assigned to match length, between 0.0 and 1.0.
38+
MatchLengthWeight float64 `json:"matchLengthWeight,omitempty"`
39+
// Normalization factor for match length in terms of tokens.
40+
// Used only when MatchLengthWeight > 0.
41+
MatchLengthScaleTokens int `json:"matchLengthScaleTokens,omitempty"`
3642
}
3743

3844
// Plugin implements the prefix cache aware scoring logic.
3945
type Plugin struct {
40-
typedName plugin.TypedName
41-
prefixMatchDataKey plugin.DataKey
46+
typedName plugin.TypedName
47+
prefixMatchDataKey plugin.DataKey
48+
matchLengthWeight float64
49+
matchLengthScaleTokens int
4250
}
4351

4452
// compile-time type assertions
@@ -49,11 +57,19 @@ var (
4957
const (
5058
// Type is the unique identifier for the prefix cache scorer plugin.
5159
PrefixCacheScorerPluginType = "prefix-cache-scorer"
60+
// The default weight of the absolute match length in the score.
61+
// Set to 0 so by default only the match ratio is considered.
62+
defaultMatchLengthWeight = 0.0
63+
// Default number of tokens used as a scaling factor.
64+
defaultMatchLengthScaleTokens = 8192
5265
)
5366

5467
// PrefixCachePluginFactory defines the factory function for the Prefix plugin.
5568
func PrefixCachePluginFactory(name string, decoder *json.Decoder, handle plugin.Handle) (plugin.Plugin, error) {
56-
var cfg Config
69+
cfg := Config{
70+
MatchLengthWeight: defaultMatchLengthWeight,
71+
MatchLengthScaleTokens: defaultMatchLengthScaleTokens,
72+
}
5773
if decoder != nil {
5874
if err := decoder.Decode(&cfg); err != nil {
5975
return nil, fmt.Errorf("failed to decode prefix cache scorer parameters: %w", err)
@@ -64,6 +80,17 @@ func PrefixCachePluginFactory(name string, decoder *json.Decoder, handle plugin.
6480
if err != nil {
6581
return nil, err
6682
}
83+
84+
if cfg.MatchLengthWeight < 0.0 || cfg.MatchLengthWeight > 1.0 {
85+
return nil, fmt.Errorf("matchLengthWeight must be between 0.0 and 1.0, got %f", cfg.MatchLengthWeight)
86+
}
87+
p.matchLengthWeight = cfg.MatchLengthWeight
88+
89+
if p.matchLengthWeight > 0.0 && cfg.MatchLengthScaleTokens <= 0 {
90+
return nil, fmt.Errorf("matchLengthScaleTokens must be greater than 0 when matchLengthWeight is greater than 0, got %d", cfg.MatchLengthScaleTokens)
91+
}
92+
p.matchLengthScaleTokens = cfg.MatchLengthScaleTokens
93+
6794
return p, nil
6895
}
6996

@@ -114,13 +141,29 @@ func (p *Plugin) Score(ctx context.Context, _ *fwksched.InferenceRequest, endpoi
114141
continue
115142
}
116143

117-
if prefixMatchInfo, ok := info.(*attrprefix.PrefixCacheMatchInfo); ok {
118-
if prefixMatchInfo.TotalBlocks() != 0 {
119-
scores[endpoint] = float64(prefixMatchInfo.MatchBlocks()) / float64(prefixMatchInfo.TotalBlocks())
120-
}
121-
} else {
144+
prefixMatchInfo, ok := info.(*attrprefix.PrefixCacheMatchInfo)
145+
if !ok {
122146
logger.V(logutil.DEFAULT).Error(nil, "PrefixCacheMatchInfo has unexpected type, assigning score 0", "endpoint", endpoint)
147+
continue
148+
}
149+
150+
matchBlocks := prefixMatchInfo.MatchBlocks()
151+
totalBlocks := prefixMatchInfo.TotalBlocks()
152+
if totalBlocks == 0 {
153+
logger.V(logutil.DEFAULT).Error(nil, "totalBlocks is set to 0, assigning score 0", "endpoint", endpoint)
154+
continue
155+
}
156+
157+
matchRatioScore := float64(matchBlocks) / float64(totalBlocks)
158+
blockSize := prefixMatchInfo.BlockSizeTokens()
159+
matchLengthScore := 0.0
160+
// Calculate matchLengthScore when match length is considered
161+
if p.matchLengthWeight > 0.0 && blockSize > 0 {
162+
// (matchBlocks * blockSize / matchLengthScaleTokens) ^ 2
163+
normalizedMatchLength := math.Min(1.0, float64(matchBlocks)*float64(blockSize)/float64(p.matchLengthScaleTokens))
164+
matchLengthScore = normalizedMatchLength * normalizedMatchLength
123165
}
166+
scores[endpoint] += p.matchLengthWeight*matchLengthScore + (1.0-p.matchLengthWeight)*matchRatioScore
124167
}
125168
return scores
126169
}

pkg/epp/framework/plugins/scheduling/scorer/prefix/plugin_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ package prefix
1818

1919
import (
2020
"context"
21+
"encoding/json"
22+
"strings"
2123
"testing"
2224

2325
"github.com/stretchr/testify/assert"
2426
k8stypes "k8s.io/apimachinery/pkg/types"
2527

2628
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
29+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
2730
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
2831
attrprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/prefix"
2932
)
@@ -46,3 +49,110 @@ func TestPrefixPluginScore(t *testing.T) {
4649
assert.Equal(t, 0.5, scores[endpoint1])
4750
assert.Equal(t, 0.2, scores[endpoint2])
4851
}
52+
53+
func TestPrefixPluginScoreWithWeights(t *testing.T) {
54+
producerName := "approx-prefix-cache-producer"
55+
// matchLengthWeight = 0.5, matchLengthScaleTokens = 100
56+
p, _ := New(context.Background(), PrefixCacheScorerPluginType, producerName)
57+
p.matchLengthWeight = 0.5
58+
p.matchLengthScaleTokens = 100
59+
60+
key := attrprefix.PrefixCacheMatchInfoDataKey.WithNonEmptyProducerName(producerName).String()
61+
62+
// Endpoint 1: match 5, total 10, block size 1
63+
// matchRatio = 5/10 = 0.5
64+
// matchLengthRatio = min(1.0, 5*1/100) = 0.05 -> squared = 0.0025
65+
// score = 0.5 * 0.0025 + 0.5 * 0.5 = 0.25125
66+
endpoint1 := fwksched.NewEndpoint(&fwkdl.EndpointMetadata{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, fwkdl.NewMetrics(), nil)
67+
endpoint1.Put(key, attrprefix.NewPrefixCacheMatchInfo(5, 10, 1))
68+
69+
// Endpoint 2: match 50, total 100, block size 1
70+
// matchRatio = 50/100 = 0.5
71+
// matchLengthRatio = min(1.0, 50*1/100) = 0.5 -> squared = 0.25
72+
// score = 0.5 * 0.25 + 0.5 * 0.5 = 0.375
73+
endpoint2 := fwksched.NewEndpoint(&fwkdl.EndpointMetadata{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, fwkdl.NewMetrics(), nil)
74+
endpoint2.Put(key, attrprefix.NewPrefixCacheMatchInfo(50, 100, 1))
75+
76+
endpoints := []fwksched.Endpoint{endpoint1, endpoint2}
77+
scores := p.Score(context.Background(), nil, endpoints)
78+
79+
// matchRatio is the same but we still give longer request higher score
80+
assert.InDelta(t, 0.25125, scores[endpoint1], 1e-6)
81+
assert.InDelta(t, 0.375, scores[endpoint2], 1e-6)
82+
}
83+
84+
func TestPrefixPluginFactoryValidation(t *testing.T) {
85+
tests := []struct {
86+
name string
87+
config string
88+
expectErr bool
89+
wantMatchLengthWeight float64
90+
wantMatchLengthScale int
91+
}{
92+
{
93+
name: "valid config with defaults",
94+
config: `{}`,
95+
expectErr: false,
96+
wantMatchLengthWeight: defaultMatchLengthWeight,
97+
wantMatchLengthScale: defaultMatchLengthScaleTokens,
98+
},
99+
{
100+
name: "valid config with custom values",
101+
config: `{"matchLengthWeight": 0.5, "matchLengthScaleTokens": 100}`,
102+
expectErr: false,
103+
wantMatchLengthWeight: 0.5,
104+
wantMatchLengthScale: 100,
105+
},
106+
{
107+
name: "invalid matchLengthWeight < 0",
108+
config: `{"matchLengthWeight": -0.1, "matchLengthScaleTokens": 100}`,
109+
expectErr: true,
110+
},
111+
{
112+
name: "invalid matchLengthWeight > 1",
113+
config: `{"matchLengthWeight": 1.1, "matchLengthScaleTokens": 100}`,
114+
expectErr: true,
115+
},
116+
{
117+
name: "invalid matchLengthScaleTokens <= 0",
118+
config: `{"matchLengthWeight": 0.5, "matchLengthScaleTokens": 0}`,
119+
expectErr: true,
120+
},
121+
{
122+
name: "missing matchLengthScaleTokens when matchLengthWeight > 0 uses default",
123+
config: `{"matchLengthWeight": 0.5}`,
124+
expectErr: false,
125+
wantMatchLengthWeight: 0.5,
126+
wantMatchLengthScale: defaultMatchLengthScaleTokens,
127+
},
128+
{
129+
name: "zero matchLengthWeight doesn't require matchLengthScaleTokens",
130+
config: `{"matchLengthWeight": 0.0}`,
131+
expectErr: false,
132+
wantMatchLengthWeight: 0.0,
133+
wantMatchLengthScale: defaultMatchLengthScaleTokens,
134+
},
135+
}
136+
137+
for _, tt := range tests {
138+
t.Run(tt.name, func(t *testing.T) {
139+
handle := plugin.NewEppHandle(context.Background(), nil)
140+
var decoder *json.Decoder
141+
if tt.config != "" {
142+
decoder = json.NewDecoder(strings.NewReader(tt.config))
143+
}
144+
p, err := PrefixCachePluginFactory("test", decoder, handle)
145+
if tt.expectErr {
146+
assert.Error(t, err)
147+
assert.Nil(t, p)
148+
} else {
149+
assert.NoError(t, err)
150+
prefixPlugin, ok := p.(*Plugin)
151+
if assert.True(t, ok, "plugin must be of type *Plugin") {
152+
assert.Equal(t, tt.wantMatchLengthWeight, prefixPlugin.matchLengthWeight)
153+
assert.Equal(t, tt.wantMatchLengthScale, prefixPlugin.matchLengthScaleTokens)
154+
}
155+
}
156+
})
157+
}
158+
}

0 commit comments

Comments
 (0)