mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
1914 lines
66 KiB
Go
1914 lines
66 KiB
Go
/*
|
|
Copyright 2025 The KServe Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package llmisvc_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/util/retry"
|
|
"k8s.io/utils/ptr"
|
|
"knative.dev/pkg/kmeta"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
igwapi "sigs.k8s.io/gateway-api-inference-extension/api/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
. "github.com/kserve/kserve/pkg/controller/v1alpha2/llmisvc/fixture"
|
|
)
|
|
|
|
var _ = Describe("LLMInferenceService Scheduler Config", func() {
|
|
Context("Inline scheduler config", func() {
|
|
It("should use inline scheduler config in the scheduler deployment", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-inline-scheduler-config"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
customSchedulerConfig := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: custom-plugin
|
|
parameters:
|
|
customParam: customValue
|
|
schedulingProfiles:
|
|
- name: custom
|
|
plugins:
|
|
- pluginRef: custom-plugin
|
|
weight: 5
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(customSchedulerConfig),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment is created and uses the inline config
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify the deployment uses the custom inline config
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find --config-text with inline config in scheduler deployment")
|
|
Expect(configText).To(ContainSubstring("custom-plugin"))
|
|
Expect(configText).To(ContainSubstring("customParam"))
|
|
|
|
// Verify status.workloads references include the scheduler
|
|
Eventually(func(g Gomega, ctx context.Context) {
|
|
current := &v1alpha2.LLMInferenceService{}
|
|
g.Expect(envTest.Get(ctx, types.NamespacedName{Name: svcName, Namespace: testNs.Name}, current)).To(Succeed())
|
|
g.Expect(current.Status.Workloads).NotTo(BeNil())
|
|
g.Expect(current.Status.Workloads.Scheduler).To(Equal(&corev1.TypedLocalObjectReference{
|
|
APIGroup: ptr.To("apps"),
|
|
Kind: "Deployment",
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
}))
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// when - remove managed scheduler
|
|
Expect(retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
current := &v1alpha2.LLMInferenceService{}
|
|
if err := envTest.Get(ctx, types.NamespacedName{Name: svcName, Namespace: testNs.Name}, current); err != nil {
|
|
return err
|
|
}
|
|
current.Spec.Router.Scheduler = nil
|
|
return envTest.Update(ctx, current)
|
|
})).To(Succeed())
|
|
|
|
// then - scheduler deployment should be deleted
|
|
Eventually(func(ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, &appsv1.Deployment{})
|
|
}).WithContext(ctx).Should(MatchError(ContainSubstring("not found")))
|
|
|
|
// status.workloads.scheduler should be nil, other refs preserved
|
|
Eventually(func(g Gomega, ctx context.Context) {
|
|
current := &v1alpha2.LLMInferenceService{}
|
|
g.Expect(envTest.Get(ctx, types.NamespacedName{Name: svcName, Namespace: testNs.Name}, current)).To(Succeed())
|
|
g.Expect(current.Status.Workloads).NotTo(BeNil())
|
|
g.Expect(current.Status.Workloads.Scheduler).To(BeNil())
|
|
g.Expect(current.Status.Workloads.Primary).NotTo(BeNil())
|
|
g.Expect(current.Status.Workloads.Service).NotTo(BeNil())
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should not override config when args already contain --config-text or --configFile", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-config-already-set"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
// Create scheduler template with existing --config-text
|
|
modelConfig := LLMInferenceServiceConfig("model-config",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
WithConfigModelName("facebook/opt-125m"),
|
|
WithConfigModelURI("hf://facebook/opt-125m"),
|
|
)
|
|
|
|
routerConfig := LLMInferenceServiceConfig("router-with-custom-args",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
)
|
|
routerConfig.Spec.Router = &v1alpha2.RouterSpec{
|
|
Gateway: &v1alpha2.GatewaySpec{},
|
|
Route: &v1alpha2.GatewayRoutesSpec{},
|
|
Scheduler: &v1alpha2.SchedulerSpec{
|
|
Template: &corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "main",
|
|
Image: "ghcr.io/llm-d/llm-d-router-endpoint-picker:v0.9.0-rc.2",
|
|
Args: []string{
|
|
"--config-text",
|
|
"existing-config-from-template",
|
|
"--poolName",
|
|
"test-pool",
|
|
},
|
|
Ports: []corev1.ContainerPort{
|
|
{Name: "grpc", ContainerPort: 9002, Protocol: corev1.ProtocolTCP},
|
|
{Name: "grpc-health", ContainerPort: 9003, Protocol: corev1.ProtocolTCP},
|
|
{Name: "metrics", ContainerPort: 9090, Protocol: corev1.ProtocolTCP},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Pool: &v1alpha2.InferencePoolSpec{},
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithBaseRefs(
|
|
corev1.LocalObjectReference{Name: "model-config"},
|
|
corev1.LocalObjectReference{Name: "router-with-custom-args"},
|
|
),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment preserves the existing --config-text from template
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify that the existing --config-text is preserved (not overwritten or duplicated)
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
Expect(configText).To(Equal("existing-config-from-template"))
|
|
Expect(countConfigTextArgs(expectedDeployment)).To(Equal(1), "Expected exactly one --config-text argument")
|
|
})
|
|
})
|
|
|
|
Context("ConfigMap ref scheduler config", func() {
|
|
It("should resolve scheduler config from ConfigMap ref and use it in deployment", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-configmap-ref-scheduler"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
configMapData := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: configmap-plugin
|
|
schedulingProfiles:
|
|
- name: configmap-profile
|
|
plugins:
|
|
- pluginRef: configmap-plugin
|
|
weight: 10
|
|
`
|
|
configMap := SchedulerConfigMap("scheduler-config-custom", testNs.Name, configMapData)
|
|
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigRef("scheduler-config-custom", ""),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment uses the config from ConfigMap
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify the deployment uses config from ConfigMap
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find --config-text with ConfigMap config in scheduler deployment")
|
|
Expect(configText).To(ContainSubstring("configmap-plugin"))
|
|
Expect(configText).To(ContainSubstring("configmap-profile"))
|
|
})
|
|
|
|
It("should use custom key from ConfigMap ref", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-configmap-custom-key"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
configMapData := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: custom-key-plugin
|
|
schedulingProfiles:
|
|
- name: custom-key-profile
|
|
plugins:
|
|
- pluginRef: custom-key-plugin
|
|
`
|
|
configMap := SchedulerConfigMapWithKey("scheduler-config-custom-key", testNs.Name, "my-custom-key", configMapData)
|
|
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigRef("scheduler-config-custom-key", "my-custom-key"),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify the deployment uses config from the custom key
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find --config-text with custom key config")
|
|
Expect(configText).To(ContainSubstring("custom-key-plugin"))
|
|
})
|
|
})
|
|
|
|
Context("ConfigMap update triggers reconciliation", func() {
|
|
It("should update scheduler deployment when referenced ConfigMap is updated", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-configmap-update"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
initialConfigData := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: initial-plugin
|
|
schedulingProfiles:
|
|
- name: initial-profile
|
|
plugins:
|
|
- pluginRef: initial-plugin
|
|
`
|
|
configMap := SchedulerConfigMap("scheduler-config-update", testNs.Name, initialConfigData)
|
|
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigRef("scheduler-config-update", ""),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// Wait for initial deployment
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify initial config
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected initial config in scheduler deployment")
|
|
Expect(configText).To(ContainSubstring("initial-plugin"))
|
|
|
|
// when - update ConfigMap
|
|
updatedConfigData := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: updated-plugin
|
|
schedulingProfiles:
|
|
- name: updated-profile
|
|
plugins:
|
|
- pluginRef: updated-plugin
|
|
`
|
|
errRetry := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
updatedConfigMap := &corev1.ConfigMap{}
|
|
if err := envTest.Get(ctx, client.ObjectKeyFromObject(configMap), updatedConfigMap); err != nil {
|
|
return err
|
|
}
|
|
updatedConfigMap.Data["epp"] = updatedConfigData
|
|
return envTest.Update(ctx, updatedConfigMap)
|
|
})
|
|
Expect(errRetry).ToNot(HaveOccurred())
|
|
|
|
// then - verify the deployment is updated with new config
|
|
updatedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
updatedDeployment = &appsv1.Deployment{}
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, updatedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check for updated config using helper function
|
|
configText, found := getSchedulerConfigText(updatedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in updated deployment")
|
|
g.Expect(configText).To(ContainSubstring("updated-plugin"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed(), fmt.Sprintf("Expected to find updated scheduler config in updated deployment %#v", updatedDeployment))
|
|
})
|
|
})
|
|
|
|
Context("Default scheduler config", func() {
|
|
It("should use default scheduler config when no config is specified (non-prefill)", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-default-scheduler-config"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
)
|
|
// No Config specified - should use default
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment uses default config (for non-prefill mode)
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify default config for non-prefill mode (should contain queue-scorer, kv-cache-utilization-scorer, etc.)
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected default config in scheduler deployment")
|
|
// Default non-prefill config should contain these plugins
|
|
Expect(configText).To(ContainSubstring("queue-scorer"))
|
|
Expect(configText).To(ContainSubstring("prefix-cache-scorer"))
|
|
Expect(configText).To(ContainSubstring("max-score-picker"))
|
|
Expect(configText).To(ContainSubstring("name: default"))
|
|
})
|
|
|
|
It("should use prefill/decode scheduler config when prefill is configured", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-prefill-scheduler-config"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithPrefill(&corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "main",
|
|
Image: "quay.io/pierdipi/vllm-cpu:latest",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment uses P/D config
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify P/D config (should contain prefill-filter, decode-filter, disagg-profile-handler, etc.)
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected P/D config in scheduler deployment")
|
|
// P/D config should contain these plugins (using new v0.7.0 names)
|
|
Expect(configText).To(ContainSubstring("disagg-headers-handler"))
|
|
Expect(configText).To(ContainSubstring("prefill-filter"))
|
|
Expect(configText).To(ContainSubstring("decode-filter"))
|
|
Expect(configText).To(ContainSubstring("disagg-profile-handler"))
|
|
Expect(configText).To(ContainSubstring("name: prefill"))
|
|
Expect(configText).To(ContainSubstring("name: decode"))
|
|
})
|
|
})
|
|
|
|
Context("System namespace ConfigMap fallback", func() {
|
|
It("should resolve ConfigMap from system namespace when not found in service namespace", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-system-ns-configmap"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
systemConfigData := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: system-namespace-plugin
|
|
schedulingProfiles:
|
|
- name: system-profile
|
|
plugins:
|
|
- pluginRef: system-namespace-plugin
|
|
`
|
|
systemConfigMap := SchedulerConfigMap("config-scheduler-system", constants.KServeNamespace, systemConfigData)
|
|
Expect(envTest.Client.Create(ctx, systemConfigMap)).To(Succeed())
|
|
defer func() {
|
|
Expect(envTest.Client.Delete(ctx, systemConfigMap)).To(Succeed())
|
|
}()
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigRef("config-scheduler-system", ""),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment uses the config from system namespace
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify the deployment uses config from system namespace
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find config from system namespace in scheduler deployment")
|
|
Expect(configText).To(ContainSubstring("system-namespace-plugin"))
|
|
})
|
|
})
|
|
|
|
Context("Scheduler config error handling", func() {
|
|
It("should report error when referenced ConfigMap does not exist", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-missing-configmap"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigRef("non-existent-configmap", ""),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - the scheduler deployment should not be created and status should indicate error
|
|
Consistently(func(g Gomega, ctx context.Context) error {
|
|
deployment := &appsv1.Deployment{}
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, deployment)
|
|
}).WithContext(ctx).
|
|
Within(2 * time.Second).
|
|
WithPolling(300 * time.Millisecond).
|
|
Should(HaveOccurred())
|
|
})
|
|
|
|
It("should report error when ConfigMap is missing the referenced key", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-missing-key"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
// Create ConfigMap without the expected key
|
|
configMap := SchedulerConfigMapWithKey("scheduler-config-wrong-key", testNs.Name, "wrong-key", "some-config")
|
|
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigRef("scheduler-config-wrong-key", ""),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - the scheduler deployment should not be created
|
|
Consistently(func(g Gomega, ctx context.Context) error {
|
|
deployment := &appsv1.Deployment{}
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, deployment)
|
|
}).WithContext(ctx).
|
|
Within(2 * time.Second).
|
|
WithPolling(300 * time.Millisecond).
|
|
Should(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Context("Scheduler config via baseRefs", func() {
|
|
It("should inherit scheduler config from baseRef LLMInferenceServiceConfig", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-baseref-scheduler-config"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
schedulerConfigData := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: baseref-plugin
|
|
schedulingProfiles:
|
|
- name: baseref-profile
|
|
plugins:
|
|
- pluginRef: baseref-plugin
|
|
`
|
|
configMap := SchedulerConfigMap("scheduler-config-baseref", testNs.Name, schedulerConfigData)
|
|
Expect(envTest.Client.Create(ctx, configMap)).To(Succeed())
|
|
|
|
// Create LLMInferenceServiceConfig with scheduler config ref
|
|
modelConfig := LLMInferenceServiceConfig("model-with-scheduler",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
WithConfigModelName("facebook/opt-125m"),
|
|
WithConfigModelURI("hf://facebook/opt-125m"),
|
|
WithConfigSchedulerConfigRef("scheduler-config-baseref", ""),
|
|
)
|
|
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
|
|
|
|
routerConfig := LLMInferenceServiceConfig("router-managed-baseref",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
WithConfigManagedRouter(),
|
|
)
|
|
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithBaseRefs(
|
|
corev1.LocalObjectReference{Name: "model-with-scheduler"},
|
|
corev1.LocalObjectReference{Name: "router-managed-baseref"},
|
|
),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment uses the config from baseRef
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify the deployment uses config inherited from baseRef
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find config from baseRef in scheduler deployment")
|
|
Expect(configText).To(ContainSubstring("baseref-plugin"))
|
|
})
|
|
|
|
It("should inherit inline scheduler config from baseRef LLMInferenceServiceConfig", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-baseref-inline-config"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
inlineSchedulerConfig := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: inline-baseref-plugin
|
|
schedulingProfiles:
|
|
- name: inline-baseref-profile
|
|
plugins:
|
|
- pluginRef: inline-baseref-plugin
|
|
`
|
|
|
|
// Create LLMInferenceServiceConfig with inline scheduler config
|
|
modelConfig := LLMInferenceServiceConfig("model-with-inline-scheduler",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
WithConfigModelName("facebook/opt-125m"),
|
|
WithConfigModelURI("hf://facebook/opt-125m"),
|
|
WithConfigSchedulerConfigInline(inlineSchedulerConfig),
|
|
)
|
|
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
|
|
|
|
routerConfig := LLMInferenceServiceConfig("router-managed-inline",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
WithConfigManagedRouter(),
|
|
)
|
|
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithBaseRefs(
|
|
corev1.LocalObjectReference{Name: "model-with-inline-scheduler"},
|
|
corev1.LocalObjectReference{Name: "router-managed-inline"},
|
|
),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment uses the inline config from baseRef
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify the deployment uses inline config inherited from baseRef
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
Expect(found).To(BeTrue(), "Expected to find inline config from baseRef in scheduler deployment")
|
|
Expect(configText).To(ContainSubstring("inline-baseref-plugin"))
|
|
Expect(configText).To(ContainSubstring("inline-baseref-profile"))
|
|
})
|
|
})
|
|
|
|
Context("Leader election flag injection", func() {
|
|
It("should inject --ha-enable-leader-election when replicas > 1", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-ha-replicas-gt-1"
|
|
nsName := kmeta.ChildName(svcName, "-test")
|
|
namespace := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: nsName,
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
|
|
defer func() {
|
|
envTest.DeleteAll(ctx, namespace)
|
|
}()
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](nsName),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerReplicas(2),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
|
|
}()
|
|
|
|
// then
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: nsName,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
Expect(hasLeaderElectionFlag(expectedDeployment)).To(BeTrue(),
|
|
"Expected --ha-enable-leader-election flag when replicas > 1")
|
|
})
|
|
|
|
It("should NOT inject --ha-enable-leader-election when replicas = 1", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-ha-replicas-eq-1"
|
|
nsName := kmeta.ChildName(svcName, "-test")
|
|
namespace := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: nsName,
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
|
|
defer func() {
|
|
envTest.DeleteAll(ctx, namespace)
|
|
}()
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](nsName),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerReplicas(1),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
|
|
}()
|
|
|
|
// then
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: nsName,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
Expect(hasLeaderElectionFlag(expectedDeployment)).To(BeFalse(),
|
|
"Expected NO --ha-enable-leader-election flag when replicas = 1")
|
|
})
|
|
|
|
It("should NOT inject --ha-enable-leader-election when replicas is not specified", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-ha-replicas-nil"
|
|
nsName := kmeta.ChildName(svcName, "-test")
|
|
namespace := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: nsName,
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
|
|
defer func() {
|
|
envTest.DeleteAll(ctx, namespace)
|
|
}()
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](nsName),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
// No scheduler replicas specified
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
|
|
}()
|
|
|
|
// then
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: nsName,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
Expect(hasLeaderElectionFlag(expectedDeployment)).To(BeFalse(),
|
|
"Expected NO --ha-enable-leader-election flag when replicas is not specified")
|
|
})
|
|
|
|
It("should NOT duplicate --ha-enable-leader-election when flag already exists", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-ha-flag-exists"
|
|
nsName := kmeta.ChildName(svcName, "-test")
|
|
namespace := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: nsName,
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
|
|
defer func() {
|
|
envTest.DeleteAll(ctx, namespace)
|
|
}()
|
|
|
|
// Create scheduler template with existing --ha-enable-leader-election
|
|
modelConfig := LLMInferenceServiceConfig("model-ha-config",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](nsName),
|
|
WithConfigModelName("facebook/opt-125m"),
|
|
WithConfigModelURI("hf://facebook/opt-125m"),
|
|
)
|
|
|
|
routerConfig := LLMInferenceServiceConfig("router-with-ha-flag",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](nsName),
|
|
)
|
|
routerConfig.Spec.Router = &v1alpha2.RouterSpec{
|
|
Gateway: &v1alpha2.GatewaySpec{},
|
|
Route: &v1alpha2.GatewayRoutesSpec{},
|
|
Scheduler: &v1alpha2.SchedulerSpec{
|
|
Replicas: ptr.To[int32](3),
|
|
Template: &corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "main",
|
|
Image: "ghcr.io/llm-d/llm-d-router-endpoint-picker:v0.9.0-rc.2",
|
|
Args: []string{
|
|
"--ha-enable-leader-election",
|
|
"--poolName",
|
|
"test-pool",
|
|
},
|
|
Ports: []corev1.ContainerPort{
|
|
{Name: "grpc", ContainerPort: 9002, Protocol: corev1.ProtocolTCP},
|
|
{Name: "grpc-health", ContainerPort: 9003, Protocol: corev1.ProtocolTCP},
|
|
{Name: "metrics", ContainerPort: 9090, Protocol: corev1.ProtocolTCP},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Pool: &v1alpha2.InferencePoolSpec{},
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, modelConfig)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, routerConfig)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](nsName),
|
|
WithBaseRefs(
|
|
corev1.LocalObjectReference{Name: "model-ha-config"},
|
|
corev1.LocalObjectReference{Name: "router-with-ha-flag"},
|
|
),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
|
|
}()
|
|
|
|
// then
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: nsName,
|
|
}, expectedDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
Expect(countLeaderElectionFlags(expectedDeployment)).To(Equal(1),
|
|
"Expected exactly one --ha-enable-leader-election flag (not duplicated)")
|
|
})
|
|
})
|
|
|
|
Context("Scheduler RBAC", func() {
|
|
It("should create scheduler role with leases permission for leader election", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-scheduler-rbac"
|
|
nsName := kmeta.ChildName(svcName, "-test")
|
|
namespace := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: nsName,
|
|
},
|
|
}
|
|
|
|
Expect(envTest.Client.Create(ctx, namespace)).To(Succeed())
|
|
Expect(envTest.Client.Create(ctx, IstioShadowService(svcName, nsName))).To(Succeed())
|
|
defer func() {
|
|
envTest.DeleteAll(ctx, namespace)
|
|
}()
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](nsName),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
Expect(envTest.Delete(ctx, llmSvc)).To(Succeed())
|
|
}()
|
|
|
|
// then - verify the scheduler role is created with leases permission
|
|
expectedRole := &rbacv1.Role{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: svcName + "-epp-role",
|
|
Namespace: nsName,
|
|
}, expectedRole)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// Verify leases permission exists
|
|
hasLeasesPermission := false
|
|
for _, rule := range expectedRole.Rules {
|
|
for _, apiGroup := range rule.APIGroups {
|
|
if apiGroup == "coordination.k8s.io" {
|
|
for _, resource := range rule.Resources {
|
|
if resource == "leases" {
|
|
hasLeasesPermission = true
|
|
// Verify all necessary verbs for leader election
|
|
Expect(rule.Verbs).To(ContainElements("get", "list", "watch", "create", "update", "patch", "delete"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Expect(hasLeasesPermission).To(BeTrue(), "Expected scheduler role to have leases permission for leader election")
|
|
})
|
|
})
|
|
|
|
Context("UDS tokenizer config injection", func() {
|
|
It("should inject tokenizersPoolConfig when precise-prefix-cache-scorer has indexerConfig", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-uds-tokenizer-inject"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
precisePrefixConfig := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: precise-prefix-cache-scorer
|
|
parameters:
|
|
indexerConfig:
|
|
tokenProcessorConfig:
|
|
blockSize: 16
|
|
hashSeed: "42"
|
|
- type: queue-scorer
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: queue-scorer
|
|
weight: 2
|
|
- pluginRef: precise-prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(precisePrefixConfig),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the scheduler deployment has injected tokenizersPoolConfig
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
g.Expect(configText).To(ContainSubstring("modelName: base"))
|
|
g.Expect(configText).To(ContainSubstring("socketFile: /tmp/tokenizer/tokenizer-uds.socket"))
|
|
// Verify tokenProcessorConfig was migrated from indexerConfig to top-level parameters
|
|
g.Expect(configText).To(ContainSubstring("tokenProcessorConfig"))
|
|
g.Expect(configText).To(ContainSubstring("blockSize: 16"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should override existing tokenizersPoolConfig values", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-uds-tokenizer-override"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
//nolint:gosec // G101: not a credential, scheduler config YAML
|
|
configWithExistingTokenizer := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: precise-prefix-cache-scorer
|
|
parameters:
|
|
indexerConfig:
|
|
tokenProcessorConfig:
|
|
blockSize: 16
|
|
hashSeed: "42"
|
|
tokenizersPoolConfig:
|
|
modelName: "wrong-model-name"
|
|
uds:
|
|
socketFile: /wrong/path/tokenizer.socket
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: precise-prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(configWithExistingTokenizer),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the values are overridden with the correct ones
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
g.Expect(configText).To(ContainSubstring("modelName: base"))
|
|
g.Expect(configText).To(ContainSubstring("socketFile: /tmp/tokenizer/tokenizer-uds.socket"))
|
|
g.Expect(configText).NotTo(ContainSubstring("wrong-model-name"))
|
|
g.Expect(configText).NotTo(ContainSubstring("/wrong/path"))
|
|
// Verify tokenProcessorConfig was migrated from indexerConfig to top-level parameters
|
|
g.Expect(configText).To(ContainSubstring("tokenProcessorConfig"))
|
|
g.Expect(configText).To(ContainSubstring("blockSize: 16"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should not inject tokenizersPoolConfig when no precise-prefix-cache-scorer plugin", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-uds-no-precise-prefix"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
configWithoutPrecisePrefix := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: queue-scorer
|
|
- type: prefix-cache-scorer
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: queue-scorer
|
|
weight: 2
|
|
- pluginRef: prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(configWithoutPrecisePrefix),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify no tokenizersPoolConfig is injected
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
g.Expect(configText).NotTo(ContainSubstring("tokenizersPoolConfig"))
|
|
g.Expect(configText).NotTo(ContainSubstring("modelName: base"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
})
|
|
|
|
Context("tokenProcessorConfig migration from indexerConfig to top-level parameters", func() {
|
|
It("should migrate tokenProcessorConfig from indexerConfig to top-level plugin parameters", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-tpc-migrate"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
oldFormatConfig := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: precise-prefix-cache-scorer
|
|
parameters:
|
|
indexerConfig:
|
|
tokenProcessorConfig:
|
|
blockSize: 64
|
|
hashSeed: "42"
|
|
kvBlockIndexConfig:
|
|
enableMetrics: true
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: precise-prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(oldFormatConfig),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify tokenProcessorConfig was migrated to top-level parameters
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
// tokenProcessorConfig should be at top-level parameters (sibling of indexerConfig)
|
|
g.Expect(configText).To(ContainSubstring("blockSize: 64"))
|
|
g.Expect(configText).To(ContainSubstring("hashSeed: \"42\""))
|
|
// indexerConfig should still have kvBlockIndexConfig but not tokenProcessorConfig
|
|
g.Expect(configText).To(ContainSubstring("kvBlockIndexConfig"))
|
|
g.Expect(configText).To(ContainSubstring("enableMetrics: true"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should not overwrite top-level tokenProcessorConfig if already present", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-tpc-no-overwrite"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
newFormatConfig := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: precise-prefix-cache-scorer
|
|
parameters:
|
|
tokenProcessorConfig:
|
|
blockSize: 128
|
|
hashSeed: "99"
|
|
indexerConfig:
|
|
tokenProcessorConfig:
|
|
blockSize: 64
|
|
hashSeed: "42"
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: precise-prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(newFormatConfig),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify top-level tokenProcessorConfig is preserved, not overwritten
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
// Top-level values should be preserved
|
|
g.Expect(configText).To(ContainSubstring("blockSize: 128"))
|
|
g.Expect(configText).To(ContainSubstring("hashSeed: \"99\""))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
})
|
|
|
|
Context("Scheduler SA credential propagation", func() {
|
|
It("should propagate annotations and imagePullSecrets from main workload SA to generated scheduler SA", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-sa-propagation"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
// Create a main workload SA with annotations and imagePullSecrets
|
|
mainSA := &corev1.ServiceAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "main-workload-sa",
|
|
Namespace: testNs.Name,
|
|
Annotations: map[string]string{
|
|
"eks.amazonaws.com/role-arn": "arn:aws:iam::123456789012:role/my-model-role",
|
|
"custom-annotation": "custom-value",
|
|
},
|
|
},
|
|
ImagePullSecrets: []corev1.LocalObjectReference{
|
|
{Name: "my-registry-secret"},
|
|
},
|
|
}
|
|
Expect(envTest.Client.Create(ctx, mainSA)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithTemplate(&corev1.PodSpec{
|
|
ServiceAccountName: "main-workload-sa",
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "main",
|
|
Image: "quay.io/test/vllm:latest",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the generated scheduler SA has propagated credentials
|
|
schedulerSA := &corev1.ServiceAccount{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: svcName + "-epp-sa",
|
|
Namespace: testNs.Name,
|
|
}, schedulerSA); err != nil {
|
|
return err
|
|
}
|
|
|
|
g.Expect(schedulerSA.Annotations).To(HaveKeyWithValue(
|
|
"eks.amazonaws.com/role-arn", "arn:aws:iam::123456789012:role/my-model-role"))
|
|
g.Expect(schedulerSA.Annotations).To(HaveKeyWithValue(
|
|
"custom-annotation", "custom-value"))
|
|
g.Expect(schedulerSA.ImagePullSecrets).To(ContainElement(
|
|
corev1.LocalObjectReference{Name: "my-registry-secret"}))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should not add extra credentials when no main workload SA is specified", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-sa-no-propagation"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
// No WithTemplate - so no ServiceAccountName set
|
|
)
|
|
|
|
// when
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// then - verify the generated scheduler SA has no extra credentials
|
|
schedulerSA := &corev1.ServiceAccount{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, types.NamespacedName{
|
|
Name: svcName + "-epp-sa",
|
|
Namespace: testNs.Name,
|
|
}, schedulerSA)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
Expect(schedulerSA.ImagePullSecrets).To(BeEmpty())
|
|
// Annotations should only contain system-managed ones (if any), not credential annotations
|
|
Expect(schedulerSA.Annotations).ToNot(HaveKey("eks.amazonaws.com/role-arn"))
|
|
})
|
|
|
|
It("should update scheduler SA when main workload SA credentials change", func(ctx SpecContext) {
|
|
// given
|
|
svcName := "test-llm-sa-update"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
// Create a main workload SA with initial annotations
|
|
mainSA := &corev1.ServiceAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "updatable-sa",
|
|
Namespace: testNs.Name,
|
|
Annotations: map[string]string{
|
|
"eks.amazonaws.com/role-arn": "arn:aws:iam::123456789012:role/initial-role",
|
|
},
|
|
},
|
|
}
|
|
Expect(envTest.Client.Create(ctx, mainSA)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithTemplate(&corev1.PodSpec{
|
|
ServiceAccountName: "updatable-sa",
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "main",
|
|
Image: "quay.io/test/vllm:latest",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
// Wait for initial scheduler SA creation with initial annotation
|
|
schedulerSA := &corev1.ServiceAccount{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: svcName + "-epp-sa",
|
|
Namespace: testNs.Name,
|
|
}, schedulerSA); err != nil {
|
|
return err
|
|
}
|
|
|
|
g.Expect(schedulerSA.Annotations).To(HaveKeyWithValue(
|
|
"eks.amazonaws.com/role-arn", "arn:aws:iam::123456789012:role/initial-role"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
// when - update main SA annotations
|
|
errRetry := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
updatedSA := &corev1.ServiceAccount{}
|
|
if err := envTest.Get(ctx, client.ObjectKeyFromObject(mainSA), updatedSA); err != nil {
|
|
return err
|
|
}
|
|
updatedSA.Annotations["eks.amazonaws.com/role-arn"] = "arn:aws:iam::123456789012:role/updated-role"
|
|
updatedSA.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "new-secret"}}
|
|
return envTest.Update(ctx, updatedSA)
|
|
})
|
|
Expect(errRetry).ToNot(HaveOccurred())
|
|
|
|
// Trigger re-reconciliation by updating the LLMInferenceService
|
|
errRetry = retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
updatedSvc := &v1alpha2.LLMInferenceService{}
|
|
if err := envTest.Get(ctx, client.ObjectKeyFromObject(llmSvc), updatedSvc); err != nil {
|
|
return err
|
|
}
|
|
if updatedSvc.Annotations == nil {
|
|
updatedSvc.Annotations = make(map[string]string)
|
|
}
|
|
updatedSvc.Annotations["reconcile-trigger"] = "update-sa"
|
|
return envTest.Update(ctx, updatedSvc)
|
|
})
|
|
Expect(errRetry).ToNot(HaveOccurred())
|
|
|
|
// then - scheduler SA should be updated with new credentials
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
updatedSchedulerSA := &corev1.ServiceAccount{}
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: svcName + "-epp-sa",
|
|
Namespace: testNs.Name,
|
|
}, updatedSchedulerSA); err != nil {
|
|
return err
|
|
}
|
|
|
|
g.Expect(updatedSchedulerSA.Annotations).To(HaveKeyWithValue(
|
|
"eks.amazonaws.com/role-arn", "arn:aws:iam::123456789012:role/updated-role"))
|
|
g.Expect(updatedSchedulerSA.ImagePullSecrets).To(ContainElement(
|
|
corev1.LocalObjectReference{Name: "new-secret"}))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
})
|
|
|
|
Context("v0.9.0 prefix-cache-scorer parameter removal", func() {
|
|
It("should remove all parameters except prefixMatchInfoProducerName from prefix-cache-scorer", func(ctx SpecContext) {
|
|
svcName := "test-llm-pcs-param-removal"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
configWithPrefixCacheParams := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: prefix-cache-scorer
|
|
parameters:
|
|
blockSizeTokens: 16
|
|
prefixMatchInfoProducerName: my-producer
|
|
- type: queue-scorer
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: queue-scorer
|
|
weight: 2
|
|
- pluginRef: prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(configWithPrefixCacheParams),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
g.Expect(configText).To(ContainSubstring("prefixMatchInfoProducerName: my-producer"))
|
|
g.Expect(configText).NotTo(ContainSubstring("blockSizeTokens"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should remove parameters entirely when no prefixMatchInfoProducerName is present", func(ctx SpecContext) {
|
|
svcName := "test-llm-pcs-param-all-removed"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
configWithOnlyDeprecatedParams := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: prefix-cache-scorer
|
|
parameters:
|
|
blockSizeTokens: 16
|
|
- type: queue-scorer
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: queue-scorer
|
|
weight: 2
|
|
- pluginRef: prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(configWithOnlyDeprecatedParams),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
g.Expect(configText).NotTo(ContainSubstring("blockSizeTokens"))
|
|
g.Expect(configText).NotTo(ContainSubstring("parameters"))
|
|
g.Expect(configText).To(ContainSubstring("prefix-cache-scorer"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
|
|
It("should not touch prefix-cache-scorer without parameters", func(ctx SpecContext) {
|
|
svcName := "test-llm-pcs-no-params"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
configWithNoParams := `
|
|
apiVersion: inference.networking.x-k8s.io/v1alpha1
|
|
kind: EndpointPickerConfig
|
|
plugins:
|
|
- type: single-profile-handler
|
|
- type: prefix-cache-scorer
|
|
- type: queue-scorer
|
|
- type: max-score-picker
|
|
schedulingProfiles:
|
|
- name: default
|
|
plugins:
|
|
- pluginRef: queue-scorer
|
|
weight: 2
|
|
- pluginRef: prefix-cache-scorer
|
|
weight: 3
|
|
- pluginRef: max-score-picker
|
|
`
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
WithSchedulerConfigInline(configWithNoParams),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
expectedDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
if err := envTest.Get(ctx, types.NamespacedName{
|
|
Name: kmeta.ChildName(svcName, "-kserve-router-scheduler"),
|
|
Namespace: testNs.Name,
|
|
}, expectedDeployment); err != nil {
|
|
return err
|
|
}
|
|
|
|
configText, found := getSchedulerConfigText(expectedDeployment)
|
|
g.Expect(found).To(BeTrue(), "Expected to find --config-text in scheduler deployment")
|
|
g.Expect(configText).To(ContainSubstring("prefix-cache-scorer"))
|
|
return nil
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
})
|
|
|
|
Context("Multi-GPU-vendor pooling via workload label propagation", func() {
|
|
It("should create InferencePool with default workload labels that AMD pods can match", func(ctx SpecContext) {
|
|
// This test verifies the multi-GPU-vendor pooling pattern:
|
|
// 1. NVIDIA instance with default scheduler creates InferencePool with default workload labels
|
|
// 2. AMD instance with no router uses spec.labels to match the NVIDIA InferencePool selector
|
|
nvidiaSvcName := "test-llm-nvidia"
|
|
amdSvcName := "test-llm-amd"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(nvidiaSvcName))
|
|
|
|
// Create NVIDIA instance with default scheduler
|
|
nvidiaLLMSvc := LLMInferenceService(nvidiaSvcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, nvidiaLLMSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, nvidiaLLMSvc)
|
|
}()
|
|
|
|
// Verify InferencePool is created with default workload label selector
|
|
ip := &igwapi.InferencePool{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, client.ObjectKey{
|
|
Name: nvidiaSvcName + "-inference-pool",
|
|
Namespace: testNs.Name,
|
|
}, ip)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
Expect(ip.Spec.Selector.MatchLabels).To(HaveKeyWithValue(
|
|
igwapi.LabelKey("app.kubernetes.io/name"), igwapi.LabelValue(nvidiaSvcName)))
|
|
Expect(ip.Spec.Selector.MatchLabels).To(HaveKeyWithValue(
|
|
igwapi.LabelKey("kserve.io/component"), igwapi.LabelValue("workload")))
|
|
Expect(ip.Spec.Selector.MatchLabels).To(HaveKeyWithValue(
|
|
igwapi.LabelKey("app.kubernetes.io/part-of"), igwapi.LabelValue("llminferenceservice")))
|
|
|
|
// Create AMD instance with no router, using spec.labels to match NVIDIA's InferencePool
|
|
amdLLMSvc := LLMInferenceService(amdSvcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithWorkloadLabels(map[string]string{
|
|
"app.kubernetes.io/name": nvidiaSvcName,
|
|
"app.kubernetes.io/part-of": "llminferenceservice",
|
|
"kserve.io/component": "workload",
|
|
}),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, amdLLMSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, amdLLMSvc)
|
|
}()
|
|
|
|
// Verify AMD workload deployment is created and its pod template labels
|
|
// match the NVIDIA InferencePool selector
|
|
amdDeployment := &appsv1.Deployment{}
|
|
Eventually(func(g Gomega, ctx context.Context) error {
|
|
return envTest.Get(ctx, client.ObjectKey{
|
|
Name: amdSvcName + "-kserve",
|
|
Namespace: testNs.Name,
|
|
}, amdDeployment)
|
|
}).WithContext(ctx).Should(Succeed())
|
|
|
|
podLabels := amdDeployment.Spec.Template.Labels
|
|
for labelKey, labelValue := range ip.Spec.Selector.MatchLabels {
|
|
Expect(podLabels).To(HaveKeyWithValue(string(labelKey), string(labelValue)),
|
|
"AMD pod template label %q should match NVIDIA InferencePool selector", labelKey)
|
|
}
|
|
|
|
// Verify no InferencePool was created for the AMD instance
|
|
amdIP := &igwapi.InferencePool{}
|
|
err := envTest.Get(ctx, client.ObjectKey{
|
|
Name: amdSvcName + "-inference-pool",
|
|
Namespace: testNs.Name,
|
|
}, amdIP)
|
|
Expect(errors.IsNotFound(err)).To(BeTrue(),
|
|
"AMD instance should not create its own InferencePool")
|
|
|
|
// Verify no scheduler deployment was created for the AMD instance
|
|
amdScheduler := &appsv1.Deployment{}
|
|
err = envTest.Get(ctx, client.ObjectKey{
|
|
Name: amdSvcName + "-kserve-epp",
|
|
Namespace: testNs.Name,
|
|
}, amdScheduler)
|
|
Expect(errors.IsNotFound(err)).To(BeTrue(),
|
|
"AMD instance should not create a scheduler deployment")
|
|
})
|
|
})
|
|
|
|
Context("EPP port defaulting for upgrade compatibility", func() {
|
|
// The port defaulting for configs that omit port (pre-GIE-v1.2.0 upgrade
|
|
// scenario) is covered by the v1alpha2pool conversion (convertExtensionRefToV1)
|
|
// and combineBaseRefsConfig. Integration tests for the nil-port case are not
|
|
// feasible here because the v1alpha2 CEL rule rejects creating configs without
|
|
// port, and the v1alpha1 conversion defaults port to 9002 during ConvertTo.
|
|
|
|
It("should not override explicitly set EPP port", func(ctx SpecContext) {
|
|
svcName := "test-llm-port-explicit"
|
|
testNs := NewTestNamespace(ctx, envTest, WithIstioShadowService(svcName))
|
|
|
|
schedulerCfg := LLMInferenceServiceConfig("kserve-config-llm-scheduler",
|
|
InNamespace[*v1alpha2.LLMInferenceServiceConfig](testNs.Name),
|
|
)
|
|
schedulerCfg.Spec.Router = &v1alpha2.RouterSpec{
|
|
Scheduler: &v1alpha2.SchedulerSpec{
|
|
Pool: &v1alpha2.InferencePoolSpec{
|
|
Spec: &igwapi.InferencePoolSpec{
|
|
EndpointPickerRef: igwapi.EndpointPickerRef{
|
|
Kind: "Service",
|
|
Name: igwapi.ObjectName(kmeta.ChildName(svcName, "-epp-service")),
|
|
Port: ptr.To(igwapi.Port{Number: 8080}),
|
|
FailureMode: igwapi.EndpointPickerFailOpen,
|
|
},
|
|
Selector: igwapi.LabelSelector{
|
|
MatchLabels: map[igwapi.LabelKey]igwapi.LabelValue{
|
|
"app": "placeholder",
|
|
},
|
|
},
|
|
TargetPorts: []igwapi.Port{{Number: 8000}},
|
|
},
|
|
},
|
|
Template: &corev1.PodSpec{
|
|
Containers: []corev1.Container{{
|
|
Name: "main",
|
|
Image: "ghcr.io/llm-d/llm-d-inference-scheduler:v0.7.1",
|
|
Ports: []corev1.ContainerPort{
|
|
{Name: "grpc", ContainerPort: 8080, Protocol: corev1.ProtocolTCP},
|
|
{Name: "grpc-health", ContainerPort: 9003, Protocol: corev1.ProtocolTCP},
|
|
{Name: "metrics", ContainerPort: 9090, Protocol: corev1.ProtocolTCP},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
Expect(envTest.Client.Create(ctx, schedulerCfg)).To(Succeed())
|
|
|
|
llmSvc := LLMInferenceService(svcName,
|
|
InNamespace[*v1alpha2.LLMInferenceService](testNs.Name),
|
|
WithModelURI("hf://facebook/opt-125m"),
|
|
WithManagedRoute(),
|
|
WithManagedGateway(),
|
|
WithManagedScheduler(),
|
|
)
|
|
|
|
Expect(envTest.Create(ctx, llmSvc)).To(Succeed())
|
|
defer func() {
|
|
testNs.DeleteAndWait(ctx, llmSvc)
|
|
}()
|
|
|
|
ip := &igwapi.InferencePool{}
|
|
Eventually(func(g Gomega, ctx context.Context) {
|
|
g.Expect(envTest.Get(ctx, client.ObjectKey{
|
|
Name: svcName + "-inference-pool",
|
|
Namespace: testNs.Name,
|
|
}, ip)).To(Succeed())
|
|
g.Expect(ip.Spec.EndpointPickerRef.Port).NotTo(BeNil())
|
|
g.Expect(ip.Spec.EndpointPickerRef.Port.Number).To(Equal(igwapi.PortNumber(8080)))
|
|
}).WithContext(ctx).Should(Succeed())
|
|
})
|
|
})
|
|
})
|
|
|
|
// schedulerContainerName is the expected name of the main container in the scheduler deployment
|
|
const schedulerContainerName = "main"
|
|
|
|
// getSchedulerConfigText extracts the --config-text argument value from a scheduler deployment.
|
|
// Returns the config text and a boolean indicating whether it was found.
|
|
func getSchedulerConfigText(deployment *appsv1.Deployment) (configText string, found bool) {
|
|
for _, container := range deployment.Spec.Template.Spec.Containers {
|
|
if container.Name == schedulerContainerName {
|
|
for i, arg := range container.Args {
|
|
if arg == "--config-text" && i+1 < len(container.Args) {
|
|
return container.Args[i+1], true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// countConfigTextArgs counts how many --config-text arguments exist in the scheduler deployment.
|
|
// Used to verify that config is not duplicated.
|
|
func countConfigTextArgs(deployment *appsv1.Deployment) int {
|
|
count := 0
|
|
for _, container := range deployment.Spec.Template.Spec.Containers {
|
|
if container.Name == schedulerContainerName {
|
|
for _, arg := range container.Args {
|
|
if arg == "--config-text" {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// hasLeaderElectionFlag checks if the scheduler deployment has the --ha-enable-leader-election flag
|
|
func hasLeaderElectionFlag(deployment *appsv1.Deployment) bool {
|
|
return countLeaderElectionFlags(deployment) > 0
|
|
}
|
|
|
|
// countLeaderElectionFlags counts how many leader election flags exist in the scheduler deployment
|
|
func countLeaderElectionFlags(deployment *appsv1.Deployment) int {
|
|
count := 0
|
|
for _, container := range deployment.Spec.Template.Spec.Containers {
|
|
if container.Name == schedulerContainerName {
|
|
for _, arg := range container.Args {
|
|
if arg == "--ha-enable-leader-election" || arg == "-ha-enable-leader-election" {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|