Skip to content

Commit d602688

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 d602688

4 files changed

Lines changed: 560 additions & 2 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
"crypto/tls"
22+
"encoding/json"
23+
"fmt"
24+
"net"
25+
"net/http"
26+
"strconv"
27+
"sync"
28+
"time"
29+
30+
"github.com/spf13/pflag"
31+
"sigs.k8s.io/controller-runtime/pkg/log"
32+
33+
logutil "github.com/llm-d/llm-d-router/pkg/common/observability/logging"
34+
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
35+
)
36+
37+
const (
38+
// modelsPath reports each served model's max_model_len, on the same
39+
// host:port as metrics scraping.
40+
modelsPath = "/v1/models"
41+
42+
// modelLenFetchTimeout bounds a single /v1/models request, matching the
43+
// metrics scraper's client timeout.
44+
modelLenFetchTimeout = 10 * time.Second
45+
46+
// schemeFlag and insecureFlag are the metrics scraper's flags; /v1/models
47+
// reuses them so it follows the same scheme and TLS policy as /metrics. The
48+
// names are duplicated as strings to avoid an import cycle with the server
49+
// package, and default to plain http when unset.
50+
schemeFlag = "model-server-metrics-scheme"
51+
insecureFlag = "model-server-metrics-https-insecure-skip-verify"
52+
)
53+
54+
// resolveModelServerScheme returns the scheme and TLS-skip policy for reaching
55+
// the model server, taken from the metrics scraper flags and defaulting to
56+
// plain http with verification on.
57+
func resolveModelServerScheme() (scheme string, insecureSkipVerify bool) {
58+
scheme = "http"
59+
if f := pflag.Lookup(schemeFlag); f != nil && f.Changed {
60+
scheme = f.Value.String()
61+
}
62+
if f := pflag.Lookup(insecureFlag); f != nil && f.Changed {
63+
insecureSkipVerify, _ = strconv.ParseBool(f.Value.String())
64+
}
65+
return scheme, insecureSkipVerify
66+
}
67+
68+
// modelsResponse is the consumed subset of the /v1/models payload.
69+
type modelsResponse struct {
70+
Data []struct {
71+
MaxModelLen int `json:"max_model_len"`
72+
} `json:"data"`
73+
}
74+
75+
// modelLenCache resolves and caches each endpoint's max_model_len, which is
76+
// static per pod. Lookups never block the request path: a miss returns 0 and
77+
// schedules a background fetch, so the caller uses its default until resolved.
78+
type modelLenCache struct {
79+
mu sync.RWMutex
80+
// byServer holds resolved context windows; a missing key means unknown.
81+
byServer map[ServerID]int
82+
// inflight dedups concurrent fetches to one request per endpoint.
83+
inflight map[ServerID]struct{}
84+
85+
// baseCtx scopes fetches to the plugin lifetime, not the triggering request.
86+
baseCtx context.Context
87+
// scheme is the protocol used to reach the model server (http or https).
88+
scheme string
89+
client *http.Client
90+
91+
// fetchFn is the resolver, swappable in tests to avoid a real HTTP server.
92+
fetchFn func(ctx context.Context, address, port string) (int, error)
93+
}
94+
95+
// newModelLenCache returns an empty cache whose fetches are scoped to baseCtx
96+
// and use the model server's configured scheme and TLS policy. The transport is
97+
// cloned from the default so connections are pooled across endpoint fetches.
98+
func newModelLenCache(baseCtx context.Context) *modelLenCache {
99+
scheme, insecureSkipVerify := resolveModelServerScheme()
100+
transport := http.DefaultTransport.(*http.Transport).Clone()
101+
if scheme == "https" {
102+
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: insecureSkipVerify}
103+
}
104+
c := &modelLenCache{
105+
byServer: make(map[ServerID]int),
106+
inflight: make(map[ServerID]struct{}),
107+
baseCtx: baseCtx,
108+
scheme: scheme,
109+
client: &http.Client{Timeout: modelLenFetchTimeout, Transport: transport},
110+
}
111+
c.fetchFn = c.httpFetch
112+
return c
113+
}
114+
115+
// get returns ep's max_model_len, or 0 if not yet resolved (scheduling a
116+
// background fetch on the first miss).
117+
func (c *modelLenCache) get(ep fwksched.Endpoint) int {
118+
meta := ep.GetMetadata()
119+
if meta == nil {
120+
return 0
121+
}
122+
id := ServerID(meta.NamespacedName)
123+
124+
c.mu.RLock()
125+
val, known := c.byServer[id]
126+
c.mu.RUnlock()
127+
if known {
128+
return val
129+
}
130+
131+
c.scheduleFetch(id, meta.Address, meta.Port)
132+
return 0
133+
}
134+
135+
// scheduleFetch starts one background fetch for id, skipping if it is already
136+
// resolved or in flight.
137+
func (c *modelLenCache) scheduleFetch(id ServerID, address, port string) {
138+
// Claim the in-flight slot, or bail if resolved/claimed meanwhile.
139+
c.mu.Lock()
140+
if _, ok := c.byServer[id]; ok {
141+
c.mu.Unlock()
142+
return
143+
}
144+
if _, ok := c.inflight[id]; ok {
145+
c.mu.Unlock()
146+
return
147+
}
148+
c.inflight[id] = struct{}{}
149+
c.mu.Unlock()
150+
151+
go func() {
152+
// Release the slot so a failed fetch can be retried later.
153+
defer func() {
154+
c.mu.Lock()
155+
delete(c.inflight, id)
156+
c.mu.Unlock()
157+
}()
158+
159+
ctx, cancel := context.WithTimeout(c.baseCtx, modelLenFetchTimeout)
160+
defer cancel()
161+
162+
val, err := c.fetchFn(ctx, address, port)
163+
if err != nil {
164+
// Stay unknown: caller keeps its default and a later request retries.
165+
log.FromContext(ctx).V(logutil.DEFAULT).Info(
166+
"failed to fetch max_model_len; using default prefix cap",
167+
"endpoint", id.String(), "error", err.Error())
168+
return
169+
}
170+
if val <= 0 {
171+
return
172+
}
173+
c.mu.Lock()
174+
c.byServer[id] = val
175+
c.mu.Unlock()
176+
}()
177+
}
178+
179+
// remove drops id's cached value so a recreated pod re-resolves.
180+
func (c *modelLenCache) remove(id ServerID) {
181+
c.mu.Lock()
182+
delete(c.byServer, id)
183+
delete(c.inflight, id)
184+
c.mu.Unlock()
185+
}
186+
187+
// httpFetch GETs /v1/models and returns the largest max_model_len reported, so
188+
// a multi-model pod's cap still covers its longest context.
189+
func (c *modelLenCache) httpFetch(ctx context.Context, address, port string) (int, error) {
190+
url := fmt.Sprintf("%s://%s%s", c.scheme, net.JoinHostPort(address, port), modelsPath)
191+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
192+
if err != nil {
193+
return 0, err
194+
}
195+
196+
resp, err := c.client.Do(req)
197+
if err != nil {
198+
return 0, err
199+
}
200+
defer func() { _ = resp.Body.Close() }()
201+
202+
if resp.StatusCode != http.StatusOK {
203+
return 0, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url)
204+
}
205+
206+
var parsed modelsResponse
207+
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
208+
return 0, fmt.Errorf("decode /v1/models response: %w", err)
209+
}
210+
211+
maxLen := 0
212+
for _, m := range parsed.Data {
213+
if m.MaxModelLen > maxLen {
214+
maxLen = m.MaxModelLen
215+
}
216+
}
217+
if maxLen <= 0 {
218+
return 0, fmt.Errorf("no positive max_model_len in /v1/models response from %s", url)
219+
}
220+
return maxLen, nil
221+
}

0 commit comments

Comments
 (0)