Skip to content

Commit 8ae9991

Browse files
authored
test(e2e): add cache-affinity test for multimodal routing (llm-d#1562)
* test(e2e): add cache-affinity test for multimodal routing Adds a Ginkgo case under "Running multimodal cache-affinity configuration" that spins 2 decode pods, wires mm-embeddings-cache-producer + mm-embeddings-cache-scorer, and asserts two identical multimodal requests land on the same decode pod via cache-affinity scoring. Iterates over image, audio, and video in a single It block. Introduces swapToSimRenderSidecar so the EPP's loopback render endpoint runs the inference sim instead of text-only Qwen2.5-1.5B; the sim emits deterministic mm_features for all three modalities while real text-only vLLM does not. Detection is config-content based: any EPP config containing mm-embeddings-cache-producer triggers the sidecar swap. Bumps the pinned sim image to v0.9.2, which includes the audio and video mm_features stub from llm-d-inference-sim PR llm-d#539. Fixes llm-d#1541 Signed-off-by: Sam Batschelet <sbatsche@redhat.com> * test(e2e): extend mm cache-affinity test with discrimination + metrics The original same-content loop passes if the scorer always picks pod 1. Three additional assertion blocks close that gap: - A/B image symmetry: each content key independently routes back to its first-served pod, proving scoring is keyed on content hash rather than "first request wins forever". - Mixed image+audio: per-item match accounting must drive cache affinity on a multi-modality request; probes the empty-hash short-circuit in ExtractMMItems. - Metric assertions on llm_d_router_epp_encoder_cache_{hits,queries}_total with the {modality} label, wrapped in Eventually because PreRequest's LRU write runs in a wg.Go goroutine. Helpers added: - runChatCompletionWithImageAndAudio for the mixed-modality body. - getMetricValue scrapes the EPP /metrics port-forward and sums matching series; sufficient for the small label set this test asserts on. Signed-off-by: Sam Batschelet <sbatsche@redhat.com> * test(e2e): address review on multimodal cache-affinity test Rename swapToSimRenderSidecar params (eppManifests/yamlDocs), extend the mixed request to image+audio+video, and note hits<queries in the metrics assertion. Signed-off-by: Sam Batschelet <sbatsche@redhat.com> * test(e2e): add estimate token-producer per-modality metric test Runs the mm cache-affinity config with the estimate backend and asserts encoder-cache metrics are labelled per modality (image/audio/video). Fails until the estimate modality fix (llm-d#1618) lands. Signed-off-by: Sam Batschelet <sbatsche@redhat.com> --------- Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
1 parent 99b14a8 commit 8ae9991

5 files changed

Lines changed: 258 additions & 0 deletions

File tree

test/e2e/configs_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,57 @@ schedulingProfiles:
242242
weight: 10
243243
`
244244

245+
// EPP configuration for multimodal cache-affinity routing. Pairs the
246+
// mm-embeddings-cache producer+scorer with token-producer at loopback; the sim
247+
// sidecar (swapped in by usesSimRender) provides mm_features.
248+
const mmCacheAffinityConfig = `apiVersion: llm-d.ai/v1alpha1
249+
kind: EndpointPickerConfig
250+
plugins:
251+
- type: token-producer
252+
parameters:
253+
modelName: food-review
254+
vllm:
255+
url: http://localhost:8000
256+
- type: mm-embeddings-cache-producer
257+
parameters:
258+
cacheSizeInMBPerServer: 64
259+
- type: mm-embeddings-cache-scorer
260+
- type: decode-filter
261+
- type: max-score-picker
262+
- type: single-profile-handler
263+
schedulingProfiles:
264+
- name: default
265+
plugins:
266+
- pluginRef: decode-filter
267+
- pluginRef: mm-embeddings-cache-scorer
268+
weight: 10
269+
- pluginRef: max-score-picker
270+
`
271+
272+
// Same as mmCacheAffinityConfig but the token-producer uses the estimate
273+
// backend, which derives mm_features in-EPP and never calls the render endpoint.
274+
const mmCacheAffinityEstimateConfig = `apiVersion: llm-d.ai/v1alpha1
275+
kind: EndpointPickerConfig
276+
plugins:
277+
- type: token-producer
278+
parameters:
279+
estimate: {}
280+
- type: mm-embeddings-cache-producer
281+
parameters:
282+
cacheSizeInMBPerServer: 64
283+
- type: mm-embeddings-cache-scorer
284+
- type: decode-filter
285+
- type: max-score-picker
286+
- type: single-profile-handler
287+
schedulingProfiles:
288+
- name: default
289+
plugins:
290+
- pluginRef: decode-filter
291+
- pluginRef: mm-embeddings-cache-scorer
292+
weight: 10
293+
- pluginRef: max-score-picker
294+
`
295+
245296
// EPP configuration for running scale model server test
246297
const scaleConfig = `apiVersion: llm-d.ai/v1alpha1
247298
kind: EndpointPickerConfig

test/e2e/e2e_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,99 @@ var _ = ginkgo.Describe("Run end to end tests", ginkgo.Ordered, func() {
770770
})
771771
})
772772

773+
ginkgo.When("Running multimodal cache-affinity configuration", ginkgo.Label(extendedTestLabel), func() {
774+
ginkgo.It("should route identical multimodal content to the same decode pod", func() {
775+
infPoolObjects = createInferencePool(1, true)
776+
777+
decodeReplicas := 2
778+
modelServers := createModelServersDecode(decodeReplicas)
779+
epp := createEndPointPicker(mmCacheAffinityConfig)
780+
781+
_, decodePods := getModelServerPods(podSelector, prefillSelector, decodeSelector)
782+
gomega.Expect(decodePods).Should(gomega.HaveLen(decodeReplicas))
783+
784+
for _, tc := range []struct {
785+
name string
786+
send func() (string, string)
787+
}{
788+
{"image", func() (string, string) { return runChatCompletionWithImages(testImageURL) }},
789+
{"audio", runChatCompletionWithAudio},
790+
{"video", runChatCompletionWithVideo},
791+
} {
792+
ginkgo.By("modality=" + tc.name)
793+
794+
ns1, pod1 := tc.send()
795+
gomega.Expect(ns1).Should(gomega.Equal(nsName))
796+
gomega.Expect(pod1).Should(gomega.BeElementOf(decodePods))
797+
798+
ns2, pod2 := tc.send()
799+
gomega.Expect(ns2).Should(gomega.Equal(nsName))
800+
gomega.Expect(pod2).Should(gomega.Equal(pod1))
801+
}
802+
803+
// A/B symmetry: each content key independently routes back to its
804+
// first-served pod. Catches "first request wins forever" bugs that
805+
// the same-content loop above can't distinguish.
806+
ginkgo.By("A/B image symmetry")
807+
_, podA := runChatCompletionWithImages(testImageURL)
808+
_, podB := runChatCompletionWithImages(testImageURL2)
809+
_, podARe := runChatCompletionWithImages(testImageURL)
810+
gomega.Expect(podARe).Should(gomega.Equal(podA))
811+
_, podBRe := runChatCompletionWithImages(testImageURL2)
812+
gomega.Expect(podBRe).Should(gomega.Equal(podB))
813+
814+
// Mixed image + audio + video in one request. Probes per-item match
815+
// accounting and the empty-hash short-circuit in ExtractMMItems.
816+
ginkgo.By("mixed image+audio+video cache affinity")
817+
_, podMix := runChatCompletionWithImageAudioVideo(testImageURL, testAudioData, testVideoURL)
818+
_, podMixRe := runChatCompletionWithImageAudioVideo(testImageURL, testAudioData, testVideoURL)
819+
gomega.Expect(podMixRe).Should(gomega.Equal(podMix))
820+
821+
// hits is the matched subset of queries, so hits < queries (never equal).
822+
// PreRequest writes the LRU async (wg.Go) → Eventually.
823+
ginkgo.By("metrics: hits_total + queries_total")
824+
gomega.Eventually(func() float64 {
825+
return getMetricValue("llm_d_router_epp_encoder_cache_queries_total",
826+
map[string]string{"modality": "image"})
827+
}, 5*time.Second, 250*time.Millisecond).Should(gomega.BeNumerically(">=", 1))
828+
gomega.Eventually(func() float64 {
829+
return getMetricValue("llm_d_router_epp_encoder_cache_hits_total",
830+
map[string]string{"modality": "image"})
831+
}, 5*time.Second, 250*time.Millisecond).Should(gomega.BeNumerically(">=", 1))
832+
833+
testutils.DeleteObjects(testConfig, epp)
834+
testutils.DeleteObjects(testConfig, modelServers)
835+
})
836+
837+
// Estimate token-producer: encoder-cache metrics must be labelled per
838+
// modality, not folded into "image" (#1617). Depends on the fix in #1618.
839+
ginkgo.It("should label per-modality metrics with the estimate token-producer", func() {
840+
infPoolObjects = createInferencePool(1, true)
841+
842+
decodeReplicas := 2
843+
modelServers := createModelServersDecode(decodeReplicas)
844+
epp := createEndPointPicker(mmCacheAffinityEstimateConfig)
845+
846+
_, decodePods := getModelServerPods(podSelector, prefillSelector, decodeSelector)
847+
gomega.Expect(decodePods).Should(gomega.HaveLen(decodeReplicas))
848+
849+
// One request per modality so each is recorded by the producer.
850+
runChatCompletionWithImages(testImageURL)
851+
runChatCompletionWithAudio()
852+
runChatCompletionWithVideo()
853+
854+
for _, modality := range []string{"image", "audio", "video"} {
855+
gomega.Eventually(func() float64 {
856+
return getMetricValue("llm_d_router_epp_encoder_cache_queries_total",
857+
map[string]string{"modality": modality})
858+
}, 5*time.Second, 250*time.Millisecond).Should(gomega.BeNumerically(">=", 1), "modality=%s", modality)
859+
}
860+
861+
testutils.DeleteObjects(testConfig, epp)
862+
testutils.DeleteObjects(testConfig, modelServers)
863+
})
864+
})
865+
773866
ginkgo.When("Running simple non-PD KV enabled configuration", ginkgo.Label(extendedTestLabel), func() {
774867
ginkgo.It("should run successfully", func() {
775868
infPoolObjects = createInferencePool(1, true)

test/e2e/requests_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,15 @@ func runChatCompletionWithVideo() (string, string) {
216216
return runRawChatCompletion(body)
217217
}
218218

219+
// runChatCompletionWithImageAudioVideo sends one request with image_url,
220+
// input_audio, and video_url blocks to probe per-item accounting across modalities.
221+
func runChatCompletionWithImageAudioVideo(imageURL, audioData, videoURL string) (string, string) {
222+
ginkgo.By("Sending Multimodal Chat Completion Request with image + audio + video")
223+
body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":%q},"uuid":"image-mixed-0"},{"type":"input_audio","input_audio":{"data":%q,"format":"wav"}},{"type":"video_url","video_url":{"url":%q}},{"type":"text","text":"Describe what you see and hear."}]}],"max_tokens":150}`,
224+
simModelName, imageURL, audioData, videoURL)
225+
return runRawChatCompletion(body)
226+
}
227+
219228
// runChatCompletionWithImageEmbeds sends a chat completion request with an image_embeds content block
220229
// carrying a pre-encoded tensor. image_embeds is not a recognised multimodal type for encode
221230
// disaggregation, so the request routes like a text request (decode-only or prefill-decode).

test/e2e/setup_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ func createEndPointPickerHelper(eppConfig string, replicas int, isLeaderElection
185185
})
186186
if !usesTokenProducer(eppConfig) {
187187
eppYamls = removeRenderSidecar(eppYamls)
188+
} else if usesSimRender(eppConfig) {
189+
eppYamls = swapToSimRenderSidecar(eppYamls, vllmSimImage, simModelName)
188190
}
189191
eppYamls = appendEppArgs(eppYamls, eppExtraArgs)
190192

@@ -205,3 +207,16 @@ func usesTokenProducer(eppConfig string) bool {
205207
}
206208
return false
207209
}
210+
211+
// usesSimRender reports whether the config needs multimodal /render output;
212+
// such configs require the sim sidecar (text-only vLLM emits no mm_features).
213+
func usesSimRender(eppConfig string) bool {
214+
cfg, _, err := configloader.LoadRawConfig([]byte(eppConfig), ginkgo.GinkgoLogr)
215+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
216+
for _, plugin := range cfg.Plugins {
217+
if plugin.Type == "mm-embeddings-cache-producer" {
218+
return true
219+
}
220+
}
221+
return false
222+
}

test/e2e/utils_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,46 @@ const (
2929
deploymentKind = "deployment"
3030
)
3131

32+
// getMetricValue scrapes the EPP /metrics endpoint and returns the sum of all
33+
// series for metricName whose label set matches every entry in filters.
34+
// Returns 0 on scrape error or no match.
35+
func getMetricValue(metricName string, filters map[string]string) float64 {
36+
resp, err := http.Get(fmt.Sprintf("http://localhost:%s/metrics", metricsPort))
37+
if err != nil {
38+
return 0
39+
}
40+
defer resp.Body.Close()
41+
body, err := io.ReadAll(resp.Body)
42+
if err != nil {
43+
return 0
44+
}
45+
var total float64
46+
for _, line := range strings.Split(string(body), "\n") {
47+
if !strings.HasPrefix(line, metricName+"{") && !strings.HasPrefix(line, metricName+" ") {
48+
continue
49+
}
50+
match := true
51+
for k, v := range filters {
52+
if !strings.Contains(line, fmt.Sprintf("%s=%q", k, v)) {
53+
match = false
54+
break
55+
}
56+
}
57+
if !match {
58+
continue
59+
}
60+
parts := strings.Fields(line)
61+
if len(parts) < 2 {
62+
continue
63+
}
64+
f, err := strconv.ParseFloat(parts[len(parts)-1], 64)
65+
if err == nil {
66+
total += f
67+
}
68+
}
69+
return total
70+
}
71+
3272
func scaleDeployment(objects []string, increment int) {
3373
direction := "up"
3474
absIncrement := increment
@@ -247,6 +287,56 @@ func filterDocument(doc string) string {
247287
return strings.TrimRight(string(out), "\n")
248288
}
249289

290+
// swapToSimRenderSidecar rewrites the vllm-render sidecar to run the inference
291+
// sim, which emits mm_features that text-only vLLM does not.
292+
func swapToSimRenderSidecar(eppManifests []string, simImage, modelName string) []string {
293+
outputs := make([]string, len(eppManifests))
294+
for idx, manifest := range eppManifests {
295+
yamlDocs := strings.Split(manifest, "\n---")
296+
rendered := make([]string, 0, len(yamlDocs))
297+
for _, yamlDoc := range yamlDocs {
298+
if strings.TrimSpace(yamlDoc) == "" {
299+
continue
300+
}
301+
rendered = append(rendered, swapRenderSidecarInDoc(yamlDoc, simImage, modelName))
302+
}
303+
outputs[idx] = strings.Join(rendered, "\n---\n")
304+
}
305+
return outputs
306+
}
307+
308+
func swapRenderSidecarInDoc(doc, simImage, modelName string) string {
309+
obj := &unstructured.Unstructured{}
310+
err := yaml.Unmarshal([]byte(doc), &obj.Object)
311+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
312+
if len(obj.Object) == 0 || obj.GetKind() != "Deployment" {
313+
return doc
314+
}
315+
path := []string{"spec", "template", "spec", "containers"}
316+
containers, found, err := unstructured.NestedSlice(obj.Object, path...)
317+
if err != nil || !found {
318+
return doc
319+
}
320+
for i, raw := range containers {
321+
c, ok := raw.(map[string]any)
322+
if !ok || c["name"] != "vllm-render" {
323+
continue
324+
}
325+
c["image"] = simImage
326+
delete(c, "command")
327+
// --log-http: surface inbound URL/method so CI failures are diagnosable.
328+
c["args"] = []any{"--port=8000", "--model=" + modelName, "--log-http"}
329+
delete(c, "volumeMounts")
330+
containers[i] = c
331+
}
332+
err = unstructured.SetNestedSlice(obj.Object, containers, path...)
333+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
334+
removePodSpecListItem(obj, "volumes", "model-cache")
335+
out, err := yaml.Marshal(obj.Object)
336+
gomega.Expect(err).ShouldNot(gomega.HaveOccurred())
337+
return strings.TrimRight(string(out), "\n")
338+
}
339+
250340
func removePodSpecListItem(obj *unstructured.Unstructured, fieldName, itemName string) {
251341
path := []string{"spec", "template", "spec", fieldName}
252342
items, found, err := unstructured.NestedSlice(obj.Object, path...)

0 commit comments

Comments
 (0)