-
Notifications
You must be signed in to change notification settings - Fork 185
add kvcache aware plugin e2e test #1222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ package router | |
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
|
|
@@ -58,7 +59,7 @@ func TestSchedulerPluginPrefixCache(t *testing.T) { | |
| } | ||
| } | ||
| t.Logf("prefix-cache: dominant pod %d/%d (of %d log lines)", maxCount, 200, routed) | ||
| require.GreaterOrEqual(t, routed, 200/2, "expected access logs for routed requests") | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
| require.GreaterOrEqual(t, float64(maxCount)/float64(routed), 0.9) | ||
|
|
||
| waitForSchedulerPluginInMetrics(t, metricsURL, plugins.PrefixCachePluginName, "score") | ||
|
|
@@ -95,7 +96,7 @@ func TestSchedulerPluginLeastRequest(t *testing.T) { | |
| idleCount := utils.CountSelectedPodInRouterLogs(t, testCtx.KubeClient, kthenaNamespace, idlePod.Name, since) | ||
| routed := busyCount + idleCount | ||
| t.Logf("least-request: busy pool %d, idle pod %s %d (of %d log lines)", busyCount, idlePod.Name, idleCount, routed) | ||
| require.GreaterOrEqual(t, routed, 200/2, "expected access logs for routed requests") | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
| require.Greater(t, idleCount, busyCount, "least-request should prefer the idle pod over saturated pods") | ||
| require.GreaterOrEqual(t, float64(idleCount)/float64(routed), 0.9, | ||
| "least-request should route at least 90%% to the idle pod") | ||
|
|
@@ -140,7 +141,7 @@ func TestSchedulerPluginGPUCacheUsage(t *testing.T) { | |
| idleCount := utils.CountSelectedPodInRouterLogs(t, testCtx.KubeClient, kthenaNamespace, idlePod.Name, since) | ||
| routed := busyCount + idleCount | ||
| t.Logf("gpu-usage: busy pool %d, idle pod %s %d (of %d log lines)", busyCount, idlePod.Name, idleCount, routed) | ||
| require.GreaterOrEqual(t, routed, 200/2, "expected access logs for routed requests") | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
| require.Greater(t, idleCount, busyCount, "gpu-usage should prefer the idle pod over kv-cache-hot pods") | ||
| require.GreaterOrEqual(t, float64(idleCount)/float64(routed), 0.9, | ||
| "gpu-usage should route at least 90%% to the idle pod") | ||
|
|
@@ -150,6 +151,62 @@ func TestSchedulerPluginGPUCacheUsage(t *testing.T) { | |
| waitForSchedulerPluginInMetrics(t, metricsURL, plugins.GPUCacheUsagePluginName, "score") | ||
| } | ||
|
|
||
| // TestSchedulerPluginKVCacheAware verifies the full kvcache-aware chain: | ||
| // sim completions -> native ZMQ -> zmq-bridge -> runtime -> Redis -> router plugin -> routing. | ||
| func TestSchedulerPluginKVCacheAware(t *testing.T) { | ||
| ctx := context.Background() | ||
| t.Cleanup(ensureRedis(t, testCtx.KubeClient, kthenaNamespace)) | ||
|
|
||
| chatURL, metricsURL, restoreCfg := utils.ApplySchedulerConfig( | ||
| t, testCtx.KubeClient, testCtx.KthenaClient, kthenaNamespace, testNamespace, | ||
| schedulerOnlyKVCacheAware, plugincontext.ModelServerName, plugincontext.ModelName) | ||
| t.Cleanup(restoreCfg) | ||
|
|
||
| route := utils.CreateModelRouteFromFile(t, ctx, testCtx.KthenaClient, plugincontext.TestDataDir, testNamespace, "ModelRoute-plugins.yaml") | ||
| model := route.Spec.ModelName | ||
| utils.WaitForChatModelReady(t, chatURL, model, []utils.ChatMessage{utils.NewChatMessage("user", "ready")}, 90*time.Second) | ||
| // Must fit sim kv-cache-size=8 blocks (block-size=8): prompt blocks + max_tokens blocks <= 8. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't it >= 8?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no , <= 8 refers to the maximum kv-cache size of the sim (kv-cache-size=8), not a requirement of at least 8 blocks. We keep the prompt and max_tokens very small to avoid the prompt blocks + output blocks exceeding 8, which would result in a 500 error.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe need to update comment |
||
| prompt := "kthena-kvcache-e2e " + strings.Repeat("cache-block-token ", 8) | ||
|
|
||
|
Comment on lines
+168
to
+170
|
||
| pods := listReadyMockPods(t, testCtx.KubeClient, testNamespace) | ||
| require.Len(t, pods, pluginMockReplicaCount, "kvcache-aware test needs %d mock pods", pluginMockReplicaCount) | ||
| warmedPod := pods[0] | ||
|
|
||
| t.Logf("kvcache-aware: warming pod %s with %d direct chat requests (max_tokens=%d)", | ||
| warmedPod.Name, kvCacheWarmupRequests, kvCacheE2EMaxTokens) | ||
|
|
||
| // Warm one pod only: chat requests populate KV cache, runtime writes Redis. | ||
| utils.DirectChatToPod(t, warmedPod, model, prompt, kvCacheWarmupRequests, kvCacheE2EMaxTokens) | ||
| logMockPodContainerTail(t, testCtx.KubeClient, warmedPod, "zmq-bridge", 20) | ||
| waitForKVCachePodInRedis(t, testCtx.KubeClient, kthenaNamespace, warmedPod, model) | ||
|
|
||
| since := metav1.NewTime(time.Now()) | ||
|
FAUST-BENCHOU marked this conversation as resolved.
|
||
| utils.SendRouterChatRequests(t, chatURL, model, prompt, 200) | ||
| time.Sleep(2 * time.Second) | ||
|
FAUST-BENCHOU marked this conversation as resolved.
|
||
|
|
||
| warmedCount := 0 | ||
| otherCount := 0 | ||
| for _, pod := range pods { | ||
| c := utils.CountSelectedPodInRouterLogs(t, testCtx.KubeClient, kthenaNamespace, pod.Name, since) | ||
| t.Logf("kvcache-aware: pod %s selected %d/%d", pod.Name, c, 200) | ||
| if pod.Name == warmedPod.Name { | ||
| warmedCount = c | ||
| } else { | ||
| otherCount += c | ||
| } | ||
| } | ||
| routed := warmedCount + otherCount | ||
| t.Logf("kvcache-aware: warmed pod %s %d, other pods %d (of %d log lines)", warmedPod.Name, warmedCount, otherCount, routed) | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
| require.Greater(t, warmedCount, otherCount, "kvcache-aware should prefer the pod with runtime-populated redis blocks") | ||
| require.GreaterOrEqual(t, float64(warmedCount)/float64(routed), 0.9, | ||
| "kvcache-aware should route at least 90%% to the warmed pod") | ||
| require.LessOrEqual(t, float64(otherCount)/float64(routed), 0.1, | ||
| "kvcache-aware should route at most 10%% to pods without redis block mappings") | ||
|
|
||
| waitForSchedulerPluginInMetrics(t, metricsURL, plugins.KVCacheAwarePluginName, "score") | ||
| } | ||
|
|
||
| // TestSchedulerPluginLeastLatency verifies least-latency prefers the intrinsically faster | ||
| // backend when both pools are idle and scored by observed TTFT/TPOT only. | ||
| func TestSchedulerPluginLeastLatency(t *testing.T) { | ||
|
|
@@ -170,13 +227,14 @@ func TestSchedulerPluginLeastLatency(t *testing.T) { | |
| // Prime both pools after the scheduler-specific router restart. Histogram deltas need | ||
| // an initial scrape baseline, so send a small baseline batch first, then the measured | ||
| // batch that should make the fast pool strictly lower-latency than the slow pool. | ||
| const latencyPrimeMaxTokens = 32 | ||
| latencyPods := append(append([]corev1.Pod{}, fastPods...), slowPods...) | ||
| for _, pod := range latencyPods { | ||
| utils.DirectChatToPod(t, pod, model, "kthena-router-plugin-e2e-fixed-prompt-latency-baseline-prime", 2) | ||
| utils.DirectChatToPod(t, pod, model, "kthena-router-plugin-e2e-fixed-prompt-latency-baseline-prime", 2, latencyPrimeMaxTokens) | ||
| } | ||
| time.Sleep(2 * time.Second) | ||
| for _, pod := range latencyPods { | ||
| utils.DirectChatToPod(t, pod, model, "kthena-router-plugin-e2e-fixed-prompt-latency-measured-prime", 8) | ||
| utils.DirectChatToPod(t, pod, model, "kthena-router-plugin-e2e-fixed-prompt-latency-measured-prime", 8, latencyPrimeMaxTokens) | ||
| } | ||
| waitForLeastLatencyMetricsSeparation(t, testCtx.KubeClient, kthenaNamespace, fastPods, slowPods) | ||
|
|
||
|
|
@@ -188,7 +246,7 @@ func TestSchedulerPluginLeastLatency(t *testing.T) { | |
| slowCount := utils.CountSelectedPodsInRouterLogs(t, testCtx.KubeClient, kthenaNamespace, since, slowPods) | ||
| routed := fastCount + slowCount | ||
| t.Logf("least-latency: fast pool %d, slow pool %d (of %d log lines)", fastCount, slowCount, routed) | ||
| require.GreaterOrEqual(t, routed, 200/2, "expected access logs for routed requests") | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
| require.Greater(t, fastCount, slowCount, "least-latency should prefer the faster backend when both pools are idle") | ||
| require.GreaterOrEqual(t, float64(fastCount)/float64(routed), 0.9, | ||
| "least-latency should route at least 90%% to the fast pool") | ||
|
|
@@ -230,7 +288,7 @@ func TestSchedulerPluginLoraAffinity(t *testing.T) { | |
| } | ||
| routed := loadedCount + otherCount | ||
| t.Logf("lora-affinity: loaded pod %s %d, other pods %d (of %d log lines)", loadedPod.Name, loadedCount, otherCount, routed) | ||
| require.GreaterOrEqual(t, routed, 200/2, "expected access logs for routed requests") | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
| require.Equal(t, 0, otherCount, "lora-affinity filter should not route to pods without the adapter") | ||
| require.GreaterOrEqual(t, float64(loadedCount)/float64(routed), 0.9, | ||
| "lora-affinity should route at least 90%% to the pod that loaded the adapter") | ||
|
|
@@ -261,7 +319,7 @@ func TestSchedulerPluginRandom(t *testing.T) { | |
| routed += c | ||
| t.Logf("random: pod %s selected %d/%d", pod.Name, c, 200) | ||
| } | ||
| require.GreaterOrEqual(t, routed, 200/2, "expected access logs for routed requests") | ||
| require.GreaterOrEqual(t, routed, 190, "expected access logs for at least 190 routed requests") | ||
|
|
||
| // Each pod should receive roughly 1/3 of traffic (±10% absolute ratio). | ||
| const randomMaxRatioDeviation = 0.10 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ import ( | |
| networkingv1alpha1 "github.com/volcano-sh/kthena/pkg/apis/networking/v1alpha1" | ||
| "github.com/volcano-sh/kthena/test/e2e/utils" | ||
| appsv1 "k8s.io/api/apps/v1" | ||
| corev1 "k8s.io/api/core/v1" | ||
| apierrors "k8s.io/apimachinery/pkg/api/errors" | ||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "k8s.io/client-go/kubernetes" | ||
|
|
@@ -38,12 +39,41 @@ const ( | |
| TestDataDir = "test/e2e/router/router-plugins/testdata" | ||
| SlowMockDeploymentName = "router-plugin-mock-slow" | ||
| SlowMockAppLabel = "router-plugin-mock-slow" | ||
|
|
||
| BridgeConfigName = "router-plugin-mock-bridge" | ||
| RuntimeEnvConfigName = "router-plugin-mock-runtime-env" | ||
| ) | ||
|
|
||
| // SetupPluginComponents deploys fast/slow plugin mocks and ModelServers shared by plugin e2e tests. | ||
| func SetupPluginComponents(kubeClient *kubernetes.Clientset, kthenaClient *clientset.Clientset, namespace string) error { | ||
| func SetupPluginComponents(kubeClient *kubernetes.Clientset, kthenaClient *clientset.Clientset, namespace, kthenaNamespace string) error { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Write |
||
| ctx := stdcontext.Background() | ||
|
|
||
| bridgeConfigMap := &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: BridgeConfigName, | ||
| Namespace: namespace, | ||
| }, | ||
| Data: map[string]string{ | ||
| "zmq-bridge.py": utils.ZMQBridgePy, | ||
| }, | ||
|
FAUST-BENCHOU marked this conversation as resolved.
|
||
| } | ||
| if _, err := kubeClient.CoreV1().ConfigMaps(namespace).Create(ctx, bridgeConfigMap, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { | ||
| return fmt.Errorf("create zmq-bridge configmap: %w", err) | ||
| } | ||
|
FAUST-BENCHOU marked this conversation as resolved.
|
||
|
|
||
| runtimeEnvConfigMap := &corev1.ConfigMap{ | ||
| ObjectMeta: metav1.ObjectMeta{ | ||
| Name: RuntimeEnvConfigName, | ||
| Namespace: namespace, | ||
| }, | ||
| Data: map[string]string{ | ||
| "REDIS_HOST": fmt.Sprintf("redis-server.%s.svc.cluster.local", kthenaNamespace), | ||
| }, | ||
| } | ||
| if _, err := kubeClient.CoreV1().ConfigMaps(namespace).Create(ctx, runtimeEnvConfigMap, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { | ||
| return fmt.Errorf("create runtime env configmap: %w", err) | ||
| } | ||
|
FAUST-BENCHOU marked this conversation as resolved.
FAUST-BENCHOU marked this conversation as resolved.
|
||
|
|
||
| deployment := utils.LoadYAMLFromFile[appsv1.Deployment](filepath.Join(TestDataDir, "LLM-Mock-plugins.yaml")) | ||
| deployment.Namespace = namespace | ||
| if _, err := kubeClient.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
requireassertions (which callt.FailNow()) inside a helper function that is executed withinrequire.Eventuallydefeats the purpose of retrying. If any transient error occurs (such as a temporary port-forwarding failure or a Redis ping timeout while the container is starting up), the test will fail immediately instead of waiting and retrying. RefactorsetupRedisClientto return an error so that the caller can handle it gracefully insideEventually.