Skip to content

Commit 3250359

Browse files
committed
Add weightMode toggle for multimodal cache affinity scorer
1 parent 75ff054 commit 3250359

5 files changed

Lines changed: 228 additions & 25 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Sample EPP configuration with weighted multimodal embeddings cache affinity.
2+
apiVersion: llm-d.ai/v1alpha1
3+
kind: EndpointPickerConfig
4+
plugins:
5+
- type: token-producer
6+
parameters:
7+
modelName: hf-repo/model-name # set to the model used in the vLLM deployment
8+
vllm:
9+
url: http://localhost:8000
10+
- type: endpoint-notification-source
11+
- type: metrics-data-source
12+
- type: core-metrics-extractor
13+
- type: mm-embeddings-cache-producer
14+
parameters:
15+
cacheSize: 10000
16+
weightMode: weighted
17+
- type: mm-embeddings-cache-scorer
18+
- type: precise-prefix-cache-scorer
19+
parameters:
20+
tokenProcessorConfig:
21+
blockSize: 64
22+
indexerConfig:
23+
kvBlockIndexConfig:
24+
enableMetrics: true
25+
- type: queue-scorer
26+
- type: max-score-picker
27+
- type: single-profile-handler
28+
- type: decode-filter
29+
dataLayer:
30+
sources:
31+
- pluginRef: metrics-data-source
32+
extractors:
33+
- pluginRef: core-metrics-extractor
34+
- pluginRef: endpoint-notification-source
35+
extractors:
36+
- pluginRef: mm-embeddings-cache-producer
37+
- pluginRef: precise-prefix-cache-scorer
38+
schedulingProfiles:
39+
- name: default
40+
plugins:
41+
- pluginRef: decode-filter
42+
- pluginRef: precise-prefix-cache-scorer
43+
weight: 10
44+
- pluginRef: mm-embeddings-cache-scorer
45+
weight: 4
46+
- pluginRef: queue-scorer
47+
weight: 1
48+
- pluginRef: max-score-picker

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

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ Produces multimodal embeddings cache match data for downstream scheduling plugin
88

99
For each request, the producer extracts stable multimodal item hashes from:
1010

11-
- `TokenizedPrompt.MultiModalFeatures`, when a `token-producer` is configured
11+
- `TokenizedPrompt.MultiModalFeatures`, when weighted mode is enabled and
12+
`token-producer` is configured
1213
- typed OpenAI chat-completions structured media blocks, as a lightweight fallback
14+
- `Generate.Features.MMHashes`, when present
1315

1416
It keeps an in-memory LRU map from multimodal hash to the set of pods that recently
1517
handled that item. During scheduling, it attaches `EncoderCacheMatchInfo` to each
@@ -18,15 +20,25 @@ same image, video, or audio input.
1820

1921
Repeated references to the same multimodal hash within one request count once.
2022

23+
## Weight Modes
24+
25+
- `unweighted` (default): each unique multimodal hash has size `1`. No
26+
`token-producer` dependency is declared.
27+
- `weighted`: when tokenized multimodal metadata is available, each item size is
28+
`MultiModalFeature.Length` (falls back to `1` when length is zero). Requires
29+
`token-producer` in the plugin list so placeholder lengths are available before
30+
scheduling.
31+
2132
## Inputs Consumed
2233

23-
This plugin declares:
34+
Unweighted mode does not declare upstream dependencies.
35+
36+
Weighted mode declares:
2437

25-
- `TokenizedPrompt`
38+
- `TokenizedPrompt` from `token-producer`
2639

27-
When `token-producer` is present, this orders tokenization before multimodal match
28-
data production. If tokenized prompt data is absent at runtime, the producer falls
29-
back to typed structured chat-completions media blocks.
40+
If tokenized prompt data is absent at runtime, the producer falls back to typed
41+
structured chat-completions media blocks with unit item sizes.
3042

3143
## Data Produced
3244

@@ -40,8 +52,9 @@ The producer supports the following runtime parameters:
4052

4153
- `cacheSize` (integer, default: `10000`): maximum number of multimodal hash entries
4254
retained in the best-effort pod-affinity cache.
55+
- `weightMode` (string, default: `unweighted`): `unweighted` or `weighted`.
4356

44-
**Configuration Examples:**
57+
**Unweighted configuration example (tokenizer-free chat media):**
4558

4659
```yaml
4760
plugins:
@@ -60,16 +73,19 @@ schedulingProfiles:
6073
weight: 2
6174
```
6275
76+
**Weighted configuration example:**
77+
6378
```yaml
6479
plugins:
6580
- type: token-producer
6681
parameters:
6782
modelName: Qwen/Qwen2.5-1.5B-Instruct
6883
vllm:
69-
http: http://localhost:8000
84+
url: http://localhost:8000
7085
- type: mm-embeddings-cache-producer
7186
parameters:
7287
cacheSize: 10000
88+
weightMode: weighted
7389
- type: mm-embeddings-cache-scorer
7490
schedulingProfiles:
7591
- name: decode
@@ -78,9 +94,15 @@ schedulingProfiles:
7894
weight: 4
7995
```
8096
97+
See also `deploy/config/epp-mm-embeddings-cache-config.yaml` and
98+
`deploy/config/epp-weighted-mm-embeddings-cache-config.yaml`.
99+
81100
## Operational Notes
82101

83102
- The cache is a best-effort routing signal, not a correctness dependency.
84-
- The producer remains tokenizer-free for request shapes where typed media blocks are
85-
sufficient; `token-producer` is only required when relying on upstream multimodal
86-
metadata.
103+
- `cacheSize` bounds the EPP routing LRU only; it is not a model-server encoder
104+
cache capacity knob.
105+
- Unweighted mode remains lightweight: structured chat media blocks are enough and
106+
`token-producer` is not required.
107+
- Weighted mode opts into `token-producer` so multimodal placeholder lengths can
108+
influence affinity scores.

pkg/epp/framework/plugins/requestcontrol/dataproducer/multimodal/producer.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
fwkrh "github.com/llm-d/llm-d-router/pkg/epp/framework/interface/requesthandling"
3939
"github.com/llm-d/llm-d-router/pkg/epp/framework/interface/scheduling"
4040
attrmm "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/datalayer/attribute/multimodal"
41+
tokenproducer "github.com/llm-d/llm-d-router/pkg/epp/framework/plugins/requestcontrol/dataproducer/tokenizer"
4142
k8stypes "k8s.io/apimachinery/pkg/types"
4243
)
4344

@@ -47,6 +48,11 @@ const (
4748

4849
defaultCacheSize = 10000
4950
podCleanupInterval = 2 * time.Minute
51+
52+
// WeightModeUnweighted counts each unique multimodal hash once with unit size.
53+
WeightModeUnweighted = "unweighted"
54+
// WeightModeWeighted uses token-producer multimodal placeholder lengths as item sizes.
55+
WeightModeWeighted = "weighted"
5056
)
5157

5258
var (
@@ -56,12 +62,16 @@ var (
5662
_ requestcontrol.DataProducer = &Producer{}
5763
_ requestcontrol.PreRequest = &Producer{}
5864
_ fwkdl.EndpointExtractor = &Producer{}
65+
_ plugin.ConsumerPlugin = &Producer{}
5966
)
6067

6168
// Parameters configures the multimodal encoder-cache data producer.
6269
type Parameters struct {
6370
// CacheSize defines the maximum number of mm_hash -> pod-set entries to track.
6471
CacheSize int `json:"cacheSize"`
72+
// WeightMode selects unweighted unit sizes (default) or weighted tokenized
73+
// placeholder lengths from token-producer metadata.
74+
WeightMode string `json:"weightMode,omitempty"`
6575
}
6676

6777
// Factory creates a multimodal encoder-cache data producer.
@@ -72,6 +82,12 @@ func Factory(name string, rawParameters *json.Decoder, handle plugin.Handle) (pl
7282
return nil, fmt.Errorf("failed to parse the parameters of the '%s' plugin - %w", ProducerType, err)
7383
}
7484
}
85+
if parameters.WeightMode == "" {
86+
parameters.WeightMode = WeightModeUnweighted
87+
}
88+
if parameters.WeightMode != WeightModeUnweighted && parameters.WeightMode != WeightModeWeighted {
89+
return nil, fmt.Errorf("failed to parse the parameters of the '%s' plugin - invalid weightMode %q", ProducerType, parameters.WeightMode)
90+
}
7591

7692
return New(handle.Context(), name, &parameters, handle.PodList)
7793
}
@@ -81,6 +97,7 @@ func Factory(name string, rawParameters *json.Decoder, handle plugin.Handle) (pl
8197
type Producer struct {
8298
typedName plugin.TypedName
8399
dk plugin.DataKey
100+
useWeighted bool
84101
cache *lru.Cache[string, map[string]struct{}]
85102
pluginState *plugin.PluginState
86103
podList func() []k8stypes.NamespacedName
@@ -111,9 +128,15 @@ func New(ctx context.Context, name string, params *Parameters, podList func() []
111128
return nil, fmt.Errorf("failed to create multimodal encoder-cache LRU with size %d: %w", cacheSize, err)
112129
}
113130

131+
useWeighted := false
132+
if params != nil && params.WeightMode == WeightModeWeighted {
133+
useWeighted = true
134+
}
135+
114136
p := &Producer{
115137
typedName: plugin.TypedName{Type: ProducerType, Name: name},
116138
dk: attrmm.EncoderCacheMatchInfoKey.WithNonEmptyProducerName(name),
139+
useWeighted: useWeighted,
117140
cache: cache,
118141
pluginState: plugin.NewPluginState(ctx),
119142
podList: podList,
@@ -147,6 +170,15 @@ func (p *Producer) Produces() map[plugin.DataKey]any {
147170
return map[plugin.DataKey]any{p.dk: attrmm.EncoderCacheMatchInfo{}}
148171
}
149172

173+
// Consumes declares the token-producer dependency only for weighted mode so
174+
// unweighted configs are not forced to pull in tokenization.
175+
func (p *Producer) Consumes() map[plugin.DataKey]any {
176+
if !p.useWeighted {
177+
return nil
178+
}
179+
return map[plugin.DataKey]any{tokenproducer.TokenizedPromptDataKey: scheduling.TokenizedPrompt{}}
180+
}
181+
150182
// PluginState returns request-scoped state shared between producer extension points.
151183
func (p *Producer) PluginState() *plugin.PluginState {
152184
return p.pluginState
@@ -155,7 +187,7 @@ func (p *Producer) PluginState() *plugin.PluginState {
155187
// Produce attaches multimodal encoder-cache match data to endpoints.
156188
func (p *Producer) Produce(ctx context.Context, request *scheduling.InferenceRequest, endpoints []scheduling.Endpoint) error {
157189
logger := log.FromContext(ctx).V(logging.DEBUG)
158-
requestItems := ExtractMMItems(request)
190+
requestItems := p.extractMMItems(request)
159191
if len(requestItems) == 0 {
160192
logger.Info("No multimodal content found, skipping encoder-cache match data")
161193
return nil
@@ -179,16 +211,25 @@ func (p *Producer) Produce(ctx context.Context, request *scheduling.InferenceReq
179211
return nil
180212
}
181213

214+
func (p *Producer) extractMMItems(request *scheduling.InferenceRequest) []attrmm.MatchItem {
215+
return extractMMItems(request, p.useWeighted)
216+
}
217+
182218
// ExtractMMItems returns deterministic, unique multimodal encoder-cache items
183-
// for a request. Parser-provided multimodal features are preferred; if
184-
// unavailable, typed structured media blocks are hashed from stable identifiers.
219+
// for a request using the unweighted path. Parser-provided multimodal features
220+
// are preferred; if unavailable, typed structured media blocks are hashed from
221+
// stable identifiers.
185222
func ExtractMMItems(request *scheduling.InferenceRequest) []attrmm.MatchItem {
223+
return extractMMItems(request, false)
224+
}
225+
226+
func extractMMItems(request *scheduling.InferenceRequest, useWeighted bool) []attrmm.MatchItem {
186227
if request == nil || request.Body == nil {
187228
return nil
188229
}
189230

190231
if request.Body.TokenizedPrompt != nil && len(request.Body.TokenizedPrompt.MultiModalFeatures) > 0 {
191-
return itemsFromTokenizedPrompt(request.Body.TokenizedPrompt.MultiModalFeatures)
232+
return itemsFromTokenizedPrompt(request.Body.TokenizedPrompt.MultiModalFeatures, useWeighted)
192233
}
193234

194235
if g := request.Body.Generate; g != nil && g.Features != nil && len(g.Features.MMHashes) > 0 {
@@ -215,17 +256,28 @@ func itemsFromGenerateFeatures(mmHashes map[string][]string) []attrmm.MatchItem
215256
return itemSlice(itemsByHash)
216257
}
217258

218-
func itemsFromTokenizedPrompt(features []fwkrh.MultiModalFeature) []attrmm.MatchItem {
259+
func itemsFromTokenizedPrompt(features []fwkrh.MultiModalFeature, useWeighted bool) []attrmm.MatchItem {
219260
itemsByHash := map[string]attrmm.MatchItem{}
220261
for _, feature := range features {
221-
if feature.Hash == "" {
222-
continue
223-
}
224-
addItem(itemsByHash, feature.Hash)
262+
addTokenizedItem(itemsByHash, feature, useWeighted)
225263
}
226264
return itemSlice(itemsByHash)
227265
}
228266

267+
func addTokenizedItem(itemsByHash map[string]attrmm.MatchItem, feature fwkrh.MultiModalFeature, useWeighted bool) {
268+
if feature.Hash == "" {
269+
return
270+
}
271+
if _, exists := itemsByHash[feature.Hash]; exists {
272+
return
273+
}
274+
size := 1
275+
if useWeighted && feature.Length > 0 {
276+
size = feature.Length
277+
}
278+
itemsByHash[feature.Hash] = attrmm.MatchItem{Hash: feature.Hash, Size: size}
279+
}
280+
229281
func itemsFromChat(request *fwkrh.ChatCompletionsRequest) []attrmm.MatchItem {
230282
itemsByHash := map[string]attrmm.MatchItem{}
231283
for _, message := range request.Messages {

pkg/epp/framework/plugins/requestcontrol/dataproducer/multimodal/producer_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,59 @@ func TestFactory(t *testing.T) {
4545
assert.Equal(t, "mm-producer", created.TypedName().Name)
4646
assert.Equal(t, ProducerType, created.TypedName().Type)
4747

48+
weightedRaw, err := json.Marshal(map[string]any{"weightMode": WeightModeWeighted})
49+
require.NoError(t, err)
50+
weighted, err := Factory("weighted-producer", plugin.StrictDecoder(weightedRaw), &testHandle{ctx: context.Background()})
51+
require.NoError(t, err)
52+
producer, ok := weighted.(*Producer)
53+
require.True(t, ok)
54+
assert.True(t, producer.useWeighted)
55+
assert.NotNil(t, producer.Consumes())
56+
57+
unweighted, err := Factory("unweighted-producer", plugin.StrictDecoder(json.RawMessage(`{}`)), &testHandle{ctx: context.Background()})
58+
require.NoError(t, err)
59+
unweightedProducer, ok := unweighted.(*Producer)
60+
require.True(t, ok)
61+
assert.False(t, unweightedProducer.useWeighted)
62+
assert.Nil(t, unweightedProducer.Consumes())
63+
4864
_, err = Factory("bad", plugin.StrictDecoder(json.RawMessage(`{"cacheSize":"bad"}`)), &testHandle{ctx: context.Background()})
4965
require.Error(t, err)
66+
67+
_, err = Factory("bad-weight", plugin.StrictDecoder(json.RawMessage(`{"weightMode":"invalid"}`)), &testHandle{ctx: context.Background()})
68+
require.Error(t, err)
69+
}
70+
71+
func TestExtractWeightedMMItemsFromTokenizedPrompt(t *testing.T) {
72+
items := extractMMItems(&scheduling.InferenceRequest{
73+
Body: &fwkrh.InferenceRequestBody{
74+
TokenizedPrompt: &fwkrh.TokenizedPrompt{
75+
MultiModalFeatures: []fwkrh.MultiModalFeature{
76+
{Hash: "image-a", Length: 576},
77+
{Hash: "image-b", Length: 0},
78+
{Hash: "image-a", Length: 144},
79+
},
80+
},
81+
},
82+
}, true)
83+
84+
assert.ElementsMatch(t, []attrmm.MatchItem{
85+
{Hash: "image-a", Size: 576},
86+
{Hash: "image-b", Size: 1},
87+
}, items)
88+
}
89+
90+
func TestProduceWeightedUsesPlaceholderLengths(t *testing.T) {
91+
producer := newTestProducer(t, &Parameters{WeightMode: WeightModeWeighted}, nil)
92+
podA := k8stypes.NamespacedName{Namespace: "default", Name: "pod-a"}
93+
endpointA := newEndpoint(podA)
94+
request := requestWithHashes("req-weighted", map[string]int{"hash-a": 80, "hash-c": 20})
95+
96+
require.NoError(t, producer.Produce(context.Background(), request, []scheduling.Endpoint{endpointA}))
97+
98+
assertMatchInfo(t, producer, endpointA,
99+
nil,
100+
[]attrmm.MatchItem{{Hash: "hash-a", Size: 80}, {Hash: "hash-c", Size: 20}})
50101
}
51102

52103
func TestExtractMMItemsFromTokenizedPrompt(t *testing.T) {

0 commit comments

Comments
 (0)