Skip to content

Commit 735d47e

Browse files
committed
feat(epp): add encoder cache features to the latency predictor
Multimodal requests incur encoder compute that dominates TTFT when the target pod's encoder cache misses, and the predictor cannot see this per-pod difference today. Feed the multimodal encoder-cache match data as two features, encoder_input_size and encoder_matched_size, opt-in via useEncoderCacheFeatures so existing deployments are unaffected. Signed-off-by: Kaushik Mitra <kaushikmitra@google.com>
1 parent 04cc371 commit 735d47e

12 files changed

Lines changed: 393 additions & 41 deletions

File tree

pkg/epp/framework/plugins/requestcontrol/dataproducer/predictedlatency/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ DataProducer, PreRequest, ResponseHeader, ResponseBody, ProducerPlugin, Consumer
1616
- TPOT training data collection at EOS (streaming mode)
1717
- Per-endpoint running request queue tracking (TPOT SLO priority queue)
1818
- Prefix cache score forwarding from `PrefixCacheMatchInfo` attributes
19+
- Multimodal encoder-cache size forwarding from `EncoderCacheMatchInfo` attributes
20+
(opt-in via `useEncoderCacheFeatures`)
1921
- TPOT neutralization for prefill endpoints in disaggregated serving
2022
- E2E latency metrics when `streamingMode=false`
2123

@@ -30,6 +32,8 @@ DataProducer, PreRequest, ResponseHeader, ResponseBody, ProducerPlugin, Consumer
3032
| `streamingMode` | `false` | Record TTFT on first chunk (true) vs EOS (false) |
3133
| `endpointRoleLabel` | `""` | Label key for disaggregated serving roles |
3234
| `predictInProduce` | `true` | Enable/disable bulk predictions. Set false for training-only mode |
35+
| `useEncoderCacheFeatures` | `false` | Feed multimodal encoder-cache sizes (`encoder_input_size`, `encoder_matched_size`) to the predictor. Requires (and auto-creates) a multimodal encoder-cache producer |
36+
| `encoderCacheMatchInfoProducerName` | `""` | Multimodal encoder-cache producer to read match data from. Empty defaults to the auto-created producer |
3337

3438
## Default Behavior (`streamingMode: false`)
3539

pkg/epp/framework/plugins/requestcontrol/dataproducer/predictedlatency/dataproducer_hooks.go

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
2929
attrconcurrency "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/concurrency"
3030
attrlatency "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/latency"
31+
attrmm "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/multimodal"
3132
attrprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/prefix"
3233
tokenproducer "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/tokenizer"
3334
)
@@ -59,6 +60,10 @@ func (pl *PredictedLatency) Produce(ctx context.Context, request *fwksched.Infer
5960
}
6061
predictedLatencyCtx.prefixCacheScoresForEndpoints[endpoint.GetMetadata().NamespacedName.Name] = prefixCacheScore
6162

63+
if pl.config.UseEncoderCacheFeatures {
64+
pl.captureEncoderCacheSizes(ctx, predictedLatencyCtx, endpoint)
65+
}
66+
6267
// Capture the in-flight load here, in the DAG-ordered Produce hook, and
6368
// reuse it for both the prediction features and the dispatch-time training
6469
// features. Re-reading the live attribute in PreRequest would make the
@@ -114,18 +119,55 @@ func (pl *PredictedLatency) Produce(ctx context.Context, request *fwksched.Infer
114119
return nil
115120
}
116121

122+
// captureEncoderCacheSizes stores the request's total multimodal encoder item
123+
// size and the endpoint's matched portion in the request context. The match
124+
// data is attached by the multimodal encoder-cache producer, which the DAG
125+
// orders before this plugin; absence means a text-only request and leaves the
126+
// sizes at 0.
127+
func (pl *PredictedLatency) captureEncoderCacheSizes(ctx context.Context, predictedLatencyCtx *predictedLatencyCtx, endpoint fwksched.Endpoint) {
128+
logger := log.FromContext(ctx)
129+
raw, ok := endpoint.Get(pl.encoderCacheDataKey.String())
130+
if !ok {
131+
return
132+
}
133+
matchInfo, ok := raw.(*attrmm.EncoderCacheMatchInfo)
134+
if !ok || matchInfo == nil {
135+
return
136+
}
137+
inputSize := sumItemSizes(matchInfo.RequestItems())
138+
matchedSize := sumItemSizes(matchInfo.MatchedItems())
139+
predictedLatencyCtx.encoderInputSize = inputSize
140+
predictedLatencyCtx.encoderMatchedSizeForEndpoints[endpoint.GetMetadata().NamespacedName.Name] = matchedSize
141+
logger.V(logutil.DEBUG).Info("Encoder cache sizes for pod",
142+
"pod", endpoint.GetMetadata().String(),
143+
"encoderInputSize", inputSize,
144+
"encoderMatchedSize", matchedSize)
145+
}
146+
147+
func sumItemSizes(items []attrmm.MatchItem) int {
148+
total := 0
149+
for _, item := range items {
150+
total += item.Size
151+
}
152+
return total
153+
}
154+
117155
func (pl *PredictedLatency) Produces() map[plugin.DataKey]any {
118156
return map[plugin.DataKey]any{
119157
pl.latencyPredictionInfoDataKey: attrlatency.LatencyPredictionInfo{},
120158
}
121159
}
122160

123161
func (pl *PredictedLatency) Consumes() plugin.DataDependencies {
124-
return plugin.DataDependencies{
125-
Required: map[plugin.DataKey]any{
126-
pl.prefixMatchDataKey: attrprefix.PrefixCacheMatchInfo{},
127-
pl.inFlightLoadDataKey: attrconcurrency.InFlightLoad{},
128-
tokenproducer.TokenizedPromptDataKey: fwksched.TokenizedPrompt{},
129-
},
162+
required := map[plugin.DataKey]any{
163+
pl.prefixMatchDataKey: attrprefix.PrefixCacheMatchInfo{},
164+
pl.inFlightLoadDataKey: attrconcurrency.InFlightLoad{},
165+
tokenproducer.TokenizedPromptDataKey: fwksched.TokenizedPrompt{},
166+
}
167+
// Required (not Optional) because only Required dependencies create DAG
168+
// ordering edges; the encoder-cache producer must run before this plugin.
169+
if pl.config.UseEncoderCacheFeatures {
170+
required[pl.encoderCacheDataKey] = attrmm.EncoderCacheMatchInfo{}
130171
}
172+
return plugin.DataDependencies{Required: required}
131173
}

pkg/epp/framework/plugins/requestcontrol/dataproducer/predictedlatency/dataproducer_hooks_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ import (
2121
"testing"
2222

2323
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
2425

2526
fwksched "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
2627
attrlatency "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/latency"
28+
attrmm "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/multimodal"
2729
attrprefix "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/prefix"
2830
)
2931

@@ -36,6 +38,64 @@ func TestProducesConsumes(t *testing.T) {
3638

3739
consumes := pl.Consumes()
3840
assert.Contains(t, consumes.Required, attrprefix.PrefixCacheMatchInfoDataKey)
41+
assert.NotContains(t, consumes.Required, attrmm.EncoderCacheMatchInfoKey,
42+
"encoder-cache match data must not be consumed when the feature is disabled")
43+
}
44+
45+
func TestConsumes_EncoderCacheFeatureEnabled(t *testing.T) {
46+
cfg := DefaultConfig
47+
cfg.UseEncoderCacheFeatures = true
48+
pl := NewPredictedLatency(LatencyDataProviderPluginType, cfg, nil)
49+
50+
consumes := pl.Consumes()
51+
assert.Contains(t, consumes.Required, attrmm.EncoderCacheMatchInfoKey)
52+
}
53+
54+
// TestProduce_CapturesEncoderCacheSizes verifies that Produce reads the
55+
// multimodal encoder-cache match data attached to endpoints and captures the
56+
// request's input size plus the per-endpoint matched size, leaving endpoints
57+
// without match data (text-only requests) at 0.
58+
func TestProduce_CapturesEncoderCacheSizes(t *testing.T) {
59+
cfg := DefaultConfig
60+
cfg.PredictInProduce = false
61+
cfg.UseEncoderCacheFeatures = true
62+
pl := NewPredictedLatency(LatencyDataProviderPluginType, cfg, nil)
63+
64+
request := createTestInferenceRequest("encoder-test", 0, 0)
65+
matched := createTestEndpoint("pod-matched", 0.1, 0, 0)
66+
unmatched := createTestEndpoint("pod-unmatched", 0.1, 0, 0)
67+
68+
items := []attrmm.MatchItem{{Hash: "img-a", Size: 1}, {Hash: "img-b", Size: 1}}
69+
matched.Put(pl.encoderCacheDataKey.String(), attrmm.NewEncoderCacheMatchInfo(items[:1], items))
70+
unmatched.Put(pl.encoderCacheDataKey.String(), attrmm.NewEncoderCacheMatchInfo(nil, items))
71+
72+
require.NoError(t, pl.Produce(context.Background(), request, []fwksched.Endpoint{matched, unmatched}))
73+
74+
plCtx, err := pl.getPredictedLatencyContextForRequest(request)
75+
require.NoError(t, err)
76+
assert.Equal(t, 2, plCtx.encoderInputSize)
77+
assert.Equal(t, 1, plCtx.encoderMatchedSizeForEndpoints["pod-matched"])
78+
assert.Equal(t, 0, plCtx.encoderMatchedSizeForEndpoints["pod-unmatched"])
79+
}
80+
81+
// TestProduce_EncoderCacheFeatureDisabledIgnoresMatchData is the negative
82+
// control: with the feature off, attached match data is not read.
83+
func TestProduce_EncoderCacheFeatureDisabledIgnoresMatchData(t *testing.T) {
84+
cfg := DefaultConfig
85+
cfg.PredictInProduce = false
86+
pl := NewPredictedLatency(LatencyDataProviderPluginType, cfg, nil)
87+
88+
request := createTestInferenceRequest("encoder-disabled-test", 0, 0)
89+
endpoint := createTestEndpoint("pod-a", 0.1, 0, 0)
90+
items := []attrmm.MatchItem{{Hash: "img-a", Size: 1}}
91+
endpoint.Put(pl.encoderCacheDataKey.String(), attrmm.NewEncoderCacheMatchInfo(items, items))
92+
93+
require.NoError(t, pl.Produce(context.Background(), request, []fwksched.Endpoint{endpoint}))
94+
95+
plCtx, err := pl.getPredictedLatencyContextForRequest(request)
96+
require.NoError(t, err)
97+
assert.Equal(t, 0, plCtx.encoderInputSize)
98+
assert.Empty(t, plCtx.encoderMatchedSizeForEndpoints)
3999
}
40100

41101
// TestProduce_CancelledContextDoesNotPublish verifies that when the

pkg/epp/framework/plugins/requestcontrol/dataproducer/predictedlatency/latencypredictorclient/prediction.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,16 @@ func (p *Predictor) predictBayesianRidge(req PredictionRequest, mr *MetricsRespo
167167
}
168168
c := mr.Coefficients
169169

170-
// Updated linear combination for TTFT to include prefix_cache_score
170+
// Linear combination for TTFT; encoder-cache coefficients are absent from
171+
// models trained without multimodal features and contribute 0 in that case.
171172
ttft := c.TTFTIntercept +
172173
c.TTFTCoeffs["kv_cache_percentage"]*req.KVCachePercentage +
173174
c.TTFTCoeffs["input_token_length"]*float64(req.InputTokenLength) +
174175
c.TTFTCoeffs["num_request_waiting"]*float64(req.NumRequestWaiting) +
175176
c.TTFTCoeffs["num_request_running"]*float64(req.NumRequestRunning) +
176-
c.TTFTCoeffs["prefix_cache_score"]*req.PrefixCacheScore
177+
c.TTFTCoeffs["prefix_cache_score"]*req.PrefixCacheScore +
178+
c.TTFTCoeffs["encoder_input_size"]*float64(req.EncoderInputSize) +
179+
c.TTFTCoeffs["encoder_matched_size"]*float64(req.EncoderMatchedSize)
177180

178181
// Linear combination for TPOT (remains unchanged - no prefix cache effect)
179182
tpot := c.TPOTIntercept +
@@ -252,6 +255,12 @@ func (p *Predictor) ValidatePredictionRequest(req PredictionRequest) error {
252255
if req.PrefixCacheScore < 0.0 || req.PrefixCacheScore > 1.0 {
253256
return fmt.Errorf("prefix_cache_score must be between 0.0 and 1.0, got %f", req.PrefixCacheScore)
254257
}
258+
if req.EncoderInputSize < 0 {
259+
return fmt.Errorf("encoder_input_size must be non-negative, got %d", req.EncoderInputSize)
260+
}
261+
if req.EncoderMatchedSize < 0 || req.EncoderMatchedSize > req.EncoderInputSize {
262+
return fmt.Errorf("encoder_matched_size must be between 0 and encoder_input_size (%d), got %d", req.EncoderInputSize, req.EncoderMatchedSize)
263+
}
255264
return nil
256265
}
257266

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
Copyright 2026 The llm-d 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 latencypredictorclient
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func validEncoderPredictionRequest() PredictionRequest {
27+
return PredictionRequest{
28+
KVCachePercentage: 0.5,
29+
InputTokenLength: 100,
30+
NumRequestWaiting: 1,
31+
NumRequestRunning: 1,
32+
NumTokensGenerated: 10,
33+
}
34+
}
35+
36+
func TestValidatePredictionRequest_EncoderSizes(t *testing.T) {
37+
predictor := &Predictor{}
38+
39+
tests := []struct {
40+
name string
41+
inputSize int
42+
matchedSize int
43+
wantErr bool
44+
}{
45+
{name: "both zero", inputSize: 0, matchedSize: 0, wantErr: false},
46+
{name: "partial match", inputSize: 5, matchedSize: 3, wantErr: false},
47+
{name: "full match", inputSize: 5, matchedSize: 5, wantErr: false},
48+
{name: "negative input size", inputSize: -1, matchedSize: 0, wantErr: true},
49+
{name: "negative matched size", inputSize: 5, matchedSize: -1, wantErr: true},
50+
{name: "matched exceeds input", inputSize: 5, matchedSize: 6, wantErr: true},
51+
}
52+
for _, tt := range tests {
53+
t.Run(tt.name, func(t *testing.T) {
54+
req := validEncoderPredictionRequest()
55+
req.EncoderInputSize = tt.inputSize
56+
req.EncoderMatchedSize = tt.matchedSize
57+
err := predictor.ValidatePredictionRequest(req)
58+
if tt.wantErr {
59+
assert.Error(t, err)
60+
} else {
61+
assert.NoError(t, err)
62+
}
63+
})
64+
}
65+
}
66+
67+
func TestValidateTrainingEntry_EncoderSizes(t *testing.T) {
68+
predictor := &Predictor{}
69+
entry := TrainingEntry{
70+
KVCachePercentage: 0.5,
71+
InputTokenLength: 100,
72+
NumRequestWaiting: 1,
73+
NumRequestRunning: 1,
74+
NumTokensGenerated: 10,
75+
ActualTTFT: 50.0,
76+
ActualTPOT: 5.0,
77+
}
78+
79+
entry.EncoderInputSize, entry.EncoderMatchedSize = 4, 2
80+
assert.NoError(t, predictor.ValidateTrainingEntry(entry))
81+
82+
entry.EncoderInputSize, entry.EncoderMatchedSize = -1, 0
83+
assert.Error(t, predictor.ValidateTrainingEntry(entry))
84+
85+
entry.EncoderInputSize, entry.EncoderMatchedSize = 2, 3
86+
assert.Error(t, predictor.ValidateTrainingEntry(entry))
87+
}
88+
89+
// TestPredictBayesianRidge_EncoderCoefficients verifies the TTFT linear model
90+
// applies encoder-cache coefficients when present and degrades to a zero
91+
// contribution when the model was trained without them.
92+
func TestPredictBayesianRidge_EncoderCoefficients(t *testing.T) {
93+
predictor := &Predictor{}
94+
req := validEncoderPredictionRequest()
95+
req.EncoderInputSize = 10
96+
req.EncoderMatchedSize = 4
97+
98+
withoutEncoder := &MetricsResponse{Coefficients: &ModelCoefficients{
99+
TTFTIntercept: 100,
100+
TTFTCoeffs: map[string]float64{},
101+
TPOTCoeffs: map[string]float64{},
102+
}}
103+
resp, err := predictor.predictBayesianRidge(req, withoutEncoder, 0.9, ObjectiveMean)
104+
require.NoError(t, err)
105+
assert.Equal(t, 100.0, resp.TTFT)
106+
107+
withEncoder := &MetricsResponse{Coefficients: &ModelCoefficients{
108+
TTFTIntercept: 100,
109+
TTFTCoeffs: map[string]float64{
110+
"encoder_input_size": 2,
111+
"encoder_matched_size": -1,
112+
},
113+
TPOTCoeffs: map[string]float64{},
114+
}}
115+
resp, err = predictor.predictBayesianRidge(req, withEncoder, 0.9, ObjectiveMean)
116+
require.NoError(t, err)
117+
assert.Equal(t, 100.0+2*10-1*4, resp.TTFT)
118+
}

pkg/epp/framework/plugins/requestcontrol/dataproducer/predictedlatency/latencypredictorclient/training.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ func (p *Predictor) ValidateTrainingEntry(entry TrainingEntry) error {
221221
if entry.PrefixCacheScore < 0.0 || entry.PrefixCacheScore > 1.0 {
222222
return fmt.Errorf("prefix_cache_score must be between 0.0 and 1.0, got %f", entry.PrefixCacheScore)
223223
}
224+
if entry.EncoderInputSize < 0 {
225+
return fmt.Errorf("encoder_input_size must be non-negative, got %d", entry.EncoderInputSize)
226+
}
227+
if entry.EncoderMatchedSize < 0 || entry.EncoderMatchedSize > entry.EncoderInputSize {
228+
return fmt.Errorf("encoder_matched_size must be between 0 and encoder_input_size (%d), got %d", entry.EncoderInputSize, entry.EncoderMatchedSize)
229+
}
224230
return nil
225231
}
226232

pkg/epp/framework/plugins/requestcontrol/dataproducer/predictedlatency/latencypredictorclient/types.go

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,19 @@ type PredictorInterface interface {
162162
// --- Data Models ---
163163

164164
type TrainingEntry struct {
165-
KVCachePercentage float64 `json:"kv_cache_percentage"`
166-
InputTokenLength int `json:"input_token_length"`
167-
NumRequestWaiting int `json:"num_request_waiting"`
168-
NumRequestRunning int `json:"num_request_running"`
169-
NumTokensGenerated int `json:"num_tokens_generated"`
170-
ActualTTFT float64 `json:"actual_ttft_ms"`
171-
ActualTPOT float64 `json:"actual_tpot_ms"`
172-
PrefixCacheScore float64 `json:"prefix_cache_score"`
165+
KVCachePercentage float64 `json:"kv_cache_percentage"`
166+
InputTokenLength int `json:"input_token_length"`
167+
NumRequestWaiting int `json:"num_request_waiting"`
168+
NumRequestRunning int `json:"num_request_running"`
169+
NumTokensGenerated int `json:"num_tokens_generated"`
170+
ActualTTFT float64 `json:"actual_ttft_ms"`
171+
ActualTPOT float64 `json:"actual_tpot_ms"`
172+
PrefixCacheScore float64 `json:"prefix_cache_score"`
173+
// EncoderInputSize is the total size of the request's multimodal encoder
174+
// items; EncoderMatchedSize is the portion likely present in the target
175+
// pod's encoder cache. Both are 0 for text-only requests.
176+
EncoderInputSize int `json:"encoder_input_size"`
177+
EncoderMatchedSize int `json:"encoder_matched_size"`
173178
PodType string `json:"pod_type,omitempty"` // "prefill", "decode", or "" for monolithic
174179
PrefillTokensInFlight int64 `json:"prefill_tokens_in_flight"`
175180
DecodeTokensInFlight int64 `json:"decode_tokens_in_flight"`
@@ -181,15 +186,20 @@ type BulkTrainingRequest struct {
181186
}
182187

183188
type PredictionRequest struct {
184-
KVCachePercentage float64 `json:"kv_cache_percentage"`
185-
InputTokenLength int `json:"input_token_length"`
186-
NumRequestWaiting int `json:"num_request_waiting"`
187-
NumRequestRunning int `json:"num_request_running"`
188-
NumTokensGenerated int `json:"num_tokens_generated"`
189-
PrefixCacheScore float64 `json:"prefix_cache_score"`
190-
PodType string `json:"pod_type,omitempty"` // "prefill", "decode", or "" for monolithic
191-
PrefillTokensInFlight int64 `json:"prefill_tokens_in_flight"`
192-
DecodeTokensInFlight int64 `json:"decode_tokens_in_flight"`
189+
KVCachePercentage float64 `json:"kv_cache_percentage"`
190+
InputTokenLength int `json:"input_token_length"`
191+
NumRequestWaiting int `json:"num_request_waiting"`
192+
NumRequestRunning int `json:"num_request_running"`
193+
NumTokensGenerated int `json:"num_tokens_generated"`
194+
PrefixCacheScore float64 `json:"prefix_cache_score"`
195+
// EncoderInputSize is the total size of the request's multimodal encoder
196+
// items; EncoderMatchedSize is the portion likely present in the target
197+
// pod's encoder cache. Both are 0 for text-only requests.
198+
EncoderInputSize int `json:"encoder_input_size"`
199+
EncoderMatchedSize int `json:"encoder_matched_size"`
200+
PodType string `json:"pod_type,omitempty"` // "prefill", "decode", or "" for monolithic
201+
PrefillTokensInFlight int64 `json:"prefill_tokens_in_flight"`
202+
DecodeTokensInFlight int64 `json:"decode_tokens_in_flight"`
193203
}
194204

195205
type PredictionResponse struct {

0 commit comments

Comments
 (0)