Skip to content

Commit b455c6a

Browse files
committed
feat(prefix): auto-tune prefix match cap from model max_model_len
The approximate-prefix-cache producer caps prefix matching at a fixed maxPrefixTokensToMatch (default 128k). On models with a larger context window that under-matches long shared prefixes, and it's one more knob to set per model. When autoTune is on and maxPrefixTokensToMatch is left at its default, derive the cap from each endpoint's max_model_len instead. The value is read from the model server's /v1/models, fetched once per pod off the request path and cached. Until it resolves (or if the endpoint can't be reached) we fall back to the existing default, so behaviour is unchanged in the common case. An explicitly configured cap is always respected. The fetch reuses the model-server-metrics scheme/TLS flags so /v1/models follows the same protocol as /metrics. Signed-off-by: Mayur Das <mayur.das@neevcloud.com>
1 parent 809bd62 commit b455c6a

5 files changed

Lines changed: 224 additions & 3 deletions

File tree

pkg/epp/framework/plugins/datalayer/attribute/models/data_types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type ModelDataCollection []ModelData
3737
type ModelData struct {
3838
ID string `json:"id"`
3939
Parent string `json:"parent,omitempty"`
40+
// MaxModelLen is the model's context window in tokens as reported by the
41+
// model server; zero when the server does not report it.
42+
MaxModelLen int `json:"max_model_len,omitempty"`
4043
}
4144

4245
// String returns a string representation of the model info

pkg/epp/framework/plugins/datalayer/extractor/models/extractor_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,32 @@ func TestExtractorExtract(t *testing.T) {
106106
})
107107
}
108108
}
109+
110+
// TestExtractorCapturesMaxModelLen verifies the extractor surfaces max_model_len
111+
// from the /v1/models payload onto the endpoint attribute.
112+
func TestExtractorCapturesMaxModelLen(t *testing.T) {
113+
ctx := context.Background()
114+
extPlugin, err := ModelServerExtractorFactory("test-extractor", nil, nil)
115+
if err != nil {
116+
t.Fatalf("failed to create extractor: %v", err)
117+
}
118+
extractor := extPlugin.(*ModelExtractor)
119+
ep := fwkdl.NewEndpoint(nil, nil)
120+
121+
resp := &ModelResponse{Data: []attrmodels.ModelData{{ID: "m", MaxModelLen: 131072}}}
122+
if err := extractor.Extract(ctx, fwkdl.PollInput[*ModelResponse]{Payload: resp, Endpoint: ep}); err != nil {
123+
t.Fatalf("unexpected error: %v", err)
124+
}
125+
126+
val, ok := ep.GetAttributes().Get(attrmodels.ModelsAttributeKey.String())
127+
if !ok {
128+
t.Fatal("expected models attribute to be set")
129+
}
130+
models, ok := val.(attrmodels.ModelDataCollection)
131+
if !ok {
132+
t.Fatalf("unexpected attribute type %T", val)
133+
}
134+
if len(models) != 1 || models[0].MaxModelLen != 131072 {
135+
t.Errorf("expected max_model_len 131072, got %+v", models)
136+
}
137+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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 approximateprefix
18+
19+
import (
20+
"context"
21+
"testing"
22+
23+
"github.com/google/uuid"
24+
"github.com/stretchr/testify/assert"
25+
k8stypes "k8s.io/apimachinery/pkg/types"
26+
27+
fwkdl "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/datalayer"
28+
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
29+
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
30+
attrmodels "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/models"
31+
attrprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/prefix"
32+
)
33+
34+
// modelLenEndpoint builds an endpoint whose /v1/models attribute reports the
35+
// given max_model_len values, one per model entry.
36+
func modelLenEndpoint(name string, maxModelLens ...int) fwksched.Endpoint {
37+
attrs := fwkdl.NewAttributes()
38+
if maxModelLens != nil {
39+
models := make(attrmodels.ModelDataCollection, len(maxModelLens))
40+
for i, ml := range maxModelLens {
41+
models[i] = attrmodels.ModelData{ID: "m", MaxModelLen: ml}
42+
}
43+
attrs.Put(attrmodels.ModelsAttributeKey.String(), models)
44+
}
45+
return fwksched.NewEndpoint(&fwkdl.EndpointMetadata{NamespacedName: k8stypes.NamespacedName{Name: name}}, fwkdl.NewMetrics(), attrs)
46+
}
47+
48+
// TestProduceAutoTunesFromModelLen verifies the prefix cap is derived from the
49+
// endpoint's max_model_len attribute when auto-tuning, and falls back otherwise.
50+
func TestProduceAutoTunesFromModelLen(t *testing.T) {
51+
disableMinBlockSizeClamp(t)
52+
53+
// A 20-block prompt at block size 1.
54+
tokens := make([]uint32, 20)
55+
for i := range tokens {
56+
tokens[i] = uint32(i + 1)
57+
}
58+
59+
produceBlocks := func(t *testing.T, p *dataProducer, ep fwksched.Endpoint) int {
60+
t.Helper()
61+
req := &fwksched.InferenceRequest{RequestID: uuid.NewString(), TargetModel: "m", Body: tokenizedBody(tokens)}
62+
assert.NoError(t, p.Produce(context.Background(), req, []fwksched.Endpoint{ep}))
63+
state, err := plugin.ReadPluginStateKey[*SchedulingContextState](
64+
p.PluginState(), req.RequestID, plugin.StateKey(ApproxPrefixCachePluginType))
65+
assert.NoError(t, err)
66+
return len(state.PerPromptHashes[0])
67+
}
68+
69+
autoTuneConfig := config{
70+
AutoTune: true,
71+
BlockSizeTokens: 1,
72+
MaxPrefixBlocksToMatch: defaultMaxPrefixBlocks,
73+
MaxPrefixTokensToMatch: defaultMaxPrefixTokens, // left at default -> auto-tune on
74+
LRUCapacityPerServer: defaultLRUCapacityPerServer,
75+
}
76+
77+
t.Run("caps at max_model_len when known", func(t *testing.T) {
78+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, autoTuneConfig, testHandle())
79+
assert.NoError(t, err)
80+
assert.True(t, p.config.autoTuneMaxPrefixTokens)
81+
// max_model_len = 10 -> cap = 10/blockSize(1) = 10 blocks.
82+
assert.Equal(t, 10, produceBlocks(t, p, modelLenEndpoint("pod1", 10)))
83+
})
84+
85+
t.Run("uses largest across models", func(t *testing.T) {
86+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, autoTuneConfig, testHandle())
87+
assert.NoError(t, err)
88+
assert.Equal(t, 12, produceBlocks(t, p, modelLenEndpoint("pod1", 8, 12)))
89+
})
90+
91+
t.Run("falls back to default when attribute absent", func(t *testing.T) {
92+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, autoTuneConfig, testHandle())
93+
assert.NoError(t, err)
94+
// No models attribute -> default cap (131072) far exceeds 20 blocks.
95+
assert.Equal(t, 20, produceBlocks(t, p, modelLenEndpoint("pod1")))
96+
})
97+
98+
t.Run("falls back when max_model_len is zero", func(t *testing.T) {
99+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, autoTuneConfig, testHandle())
100+
assert.NoError(t, err)
101+
// Attribute present but server did not report the field.
102+
assert.Equal(t, 20, produceBlocks(t, p, modelLenEndpoint("pod1", 0)))
103+
})
104+
105+
t.Run("explicit cap is not auto-tuned", func(t *testing.T) {
106+
explicit := autoTuneConfig
107+
explicit.MaxPrefixTokensToMatch = 5 // operator-pinned, non-default
108+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, explicit, testHandle())
109+
assert.NoError(t, err)
110+
assert.False(t, p.config.autoTuneMaxPrefixTokens)
111+
// Even with a known model length, the explicit cap (5) wins.
112+
assert.Equal(t, 5, produceBlocks(t, p, modelLenEndpoint("pod1", 10)))
113+
})
114+
115+
t.Run("empty pods uses default cap", func(t *testing.T) {
116+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, autoTuneConfig, testHandle())
117+
assert.NoError(t, err)
118+
119+
req := &fwksched.InferenceRequest{RequestID: uuid.NewString(), TargetModel: "m", Body: tokenizedBody(tokens)}
120+
assert.NoError(t, p.Produce(context.Background(), req, []fwksched.Endpoint{}))
121+
state, err := plugin.ReadPluginStateKey[*SchedulingContextState](
122+
p.PluginState(), req.RequestID, plugin.StateKey(ApproxPrefixCachePluginType))
123+
assert.NoError(t, err)
124+
assert.Equal(t, 20, len(state.PerPromptHashes[0]))
125+
})
126+
}
127+
128+
// TestConsumesDeclaresModelsDependency verifies the producer declares the
129+
// /v1/models attribute as a required dependency so its source is run.
130+
func TestConsumesDeclaresModelsDependency(t *testing.T) {
131+
p, err := newDataProducer(context.Background(), ApproxPrefixCachePluginType, defaultConfig, testHandle())
132+
assert.NoError(t, err)
133+
_, ok := p.Consumes().Required[attrmodels.ModelsAttributeKey]
134+
assert.True(t, ok, "models attribute must be a required dependency")
135+
}
136+
137+
// TestMaxModelLenHelper covers the attribute reader's non-collection and
138+
// missing-attribute paths.
139+
func TestMaxModelLenHelper(t *testing.T) {
140+
// Missing attribute -> 0.
141+
assert.Equal(t, 0, maxModelLen(modelLenEndpoint("pod1")))
142+
143+
// Wrong type stored under the key -> 0.
144+
attrs := fwkdl.NewAttributes()
145+
attrs.Put(attrmodels.ModelsAttributeKey.String(), attrprefix.NewPrefixCacheMatchInfo(0, 0, 0))
146+
ep := fwksched.NewEndpoint(&fwkdl.EndpointMetadata{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, fwkdl.NewMetrics(), attrs)
147+
assert.Equal(t, 0, maxModelLen(ep))
148+
}

pkg/epp/framework/plugins/requestcontrol/dataproducer/approximateprefix/plugin.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/plugin"
3131
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requestcontrol"
3232
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
33+
attrmodels "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/models"
3334
attrprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/prefix"
3435
approxprefixconstants "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/approximateprefix/constants"
3536
tokenproducer "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/tokenizer"
@@ -79,7 +80,11 @@ func (p *dataProducer) Produces() map[plugin.DataKey]any {
7980
// is configured.
8081
func (p *dataProducer) Consumes() plugin.DataDependencies {
8182
return plugin.DataDependencies{
82-
Required: map[plugin.DataKey]any{tokenproducer.TokenizedPromptDataKey: fwksched.TokenizedPrompt{}},
83+
Required: map[plugin.DataKey]any{
84+
tokenproducer.TokenizedPromptDataKey: fwksched.TokenizedPrompt{},
85+
// Supplies each endpoint's max_model_len for auto-tuning the cap in Produce.
86+
attrmodels.ModelsAttributeKey: attrmodels.ModelDataCollection{},
87+
},
8388
}
8489
}
8590

@@ -118,6 +123,10 @@ func newDataProducer(ctx context.Context, name string, config config, handle plu
118123

119124
indexer := newIndexer(ctx, config.LRUCapacityPerServer, name, ApproxPrefixCachePluginType)
120125

126+
// Auto-tune the cap from max_model_len only when AutoTune is on and the
127+
// operator left MaxPrefixTokensToMatch at its default (no explicit cap).
128+
config.autoTuneMaxPrefixTokens = config.AutoTune && config.MaxPrefixTokensToMatch == defaultMaxPrefixTokens
129+
121130
p := &dataProducer{
122131
typedName: plugin.TypedName{
123132
Type: ApproxPrefixCachePluginType,
@@ -175,9 +184,16 @@ func (p *dataProducer) PluginState() *plugin.PluginState {
175184
// Produce is called by the director before scheduling requests.
176185
func (p *dataProducer) Produce(ctx context.Context, request *fwksched.InferenceRequest, pods []fwksched.Endpoint) error {
177186
blockSize := p.GetBlockSize(pods)
187+
maxTokens := p.config.MaxPrefixTokensToMatch
188+
// Raise the cap to the model's full context window when max_model_len is known.
189+
if p.config.autoTuneMaxPrefixTokens && len(pods) > 0 {
190+
if modelLen := maxModelLen(pods[0]); modelLen > 0 {
191+
maxTokens = modelLen
192+
}
193+
}
178194
maxBlocks := p.config.MaxPrefixBlocksToMatch
179-
if p.config.MaxPrefixTokensToMatch > 0 && blockSize > 0 {
180-
maxBlocks = p.config.MaxPrefixTokensToMatch / blockSize
195+
if maxTokens > 0 && blockSize > 0 {
196+
maxBlocks = maxTokens / blockSize
181197
}
182198
perPromptHashes := getBlockHashes(ctx, request, blockSize, maxBlocks)
183199

@@ -307,6 +323,26 @@ func (p *dataProducer) GetBlockSize(endpoints []fwksched.Endpoint) int {
307323
return blockSize
308324
}
309325

326+
// maxModelLen returns the largest max_model_len reported for ep via the
327+
// /v1/models attribute, or 0 when absent or non-positive.
328+
func maxModelLen(ep fwksched.Endpoint) int {
329+
val, ok := ep.Get(attrmodels.ModelsAttributeKey.String())
330+
if !ok {
331+
return 0
332+
}
333+
models, ok := val.(attrmodels.ModelDataCollection)
334+
if !ok {
335+
return 0
336+
}
337+
maxLen := 0
338+
for i := range models {
339+
if models[i].MaxModelLen > maxLen {
340+
maxLen = models[i].MaxModelLen
341+
}
342+
}
343+
return maxLen
344+
}
345+
310346
// ApproxPrefixCacheFactory is the factory function for the prefix cache data producer plugin.
311347
func ApproxPrefixCacheFactory(name string, rawParameters *json.Decoder, handle plugin.Handle) (plugin.Plugin, error) {
312348
parameters := defaultConfig

pkg/epp/framework/plugins/requestcontrol/dataproducer/approximateprefix/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ type config struct {
140140
MaxPrefixTokensToMatch int `json:"maxPrefixTokensToMatch"`
141141
// Max capacity size of the LRU indexer in number of entries per server (pod).
142142
LRUCapacityPerServer int `json:"lruCapacityPerServer"`
143+
144+
// autoTuneMaxPrefixTokens, set internally (never from JSON), derives the cap
145+
// per endpoint from the model's max_model_len. Enabled when AutoTune is on and
146+
// MaxPrefixTokensToMatch was left at its default (no operator-pinned cap).
147+
autoTuneMaxPrefixTokens bool
143148
}
144149

145150
// defaultConfig provides sensible defaults for the prefix cache plugins.

0 commit comments

Comments
 (0)