From 89eacc719fe31ace669c421a828c4e37ff0b7581 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Thu, 6 Feb 2025 14:33:55 -0700 Subject: [PATCH 001/260] Revert "Replace EndpointSlice reconciler with pod list backed by informer (#271)" (#301) This reverts commit 9298849a6b39b2e636c0cfbbbfc00a762f6bfd81. --- .golangci.yml | 1 + pkg/ext-proc/backend/datastore.go | 132 ++---------- .../backend/endpointslice_reconciler.go | 109 ++++++++++ .../backend/endpointslice_reconcilier_test.go | 202 ++++++++++++++++++ pkg/ext-proc/backend/fake.go | 10 +- .../backend/inferencemodel_reconciler_test.go | 21 -- .../backend/inferencepool_reconciler.go | 1 + pkg/ext-proc/backend/provider.go | 85 ++------ pkg/ext-proc/backend/provider_test.go | 143 ++++--------- pkg/ext-proc/backend/types.go | 1 - pkg/ext-proc/health.go | 3 +- pkg/ext-proc/main.go | 26 ++- pkg/ext-proc/scheduling/filter_test.go | 5 +- pkg/ext-proc/server/runserver.go | 31 ++- pkg/ext-proc/test/utils.go | 6 +- pkg/ext-proc/util/testing/lister.go | 19 -- pkg/ext-proc/util/testing/wrappers.go | 38 ---- pkg/manifests/ext_proc.yaml | 2 + pkg/manifests/vllm/deployment.yaml | 13 ++ test/e2e/e2e_suite_test.go | 5 + test/integration/hermetic_test.go | 94 ++++---- 21 files changed, 499 insertions(+), 448 deletions(-) create mode 100644 pkg/ext-proc/backend/endpointslice_reconciler.go create mode 100644 pkg/ext-proc/backend/endpointslice_reconcilier_test.go delete mode 100644 pkg/ext-proc/util/testing/lister.go delete mode 100644 pkg/ext-proc/util/testing/wrappers.go diff --git a/.golangci.yml b/.golangci.yml index 2ad3b93d..1462bcc7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,6 +14,7 @@ linters: - dupword - durationcheck - fatcontext + - gci - ginkgolinter - gocritic - govet diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/backend/datastore.go index 627ddbe5..b466a2ed 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/backend/datastore.go @@ -1,26 +1,13 @@ package backend import ( - "context" "errors" "math/rand" "sync" - "time" - "github.com/google/go-cmp/cmp" "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" corev1 "k8s.io/api/core/v1" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/client-go/informers" - informersv1 "k8s.io/client-go/informers/core/v1" - "k8s.io/client-go/kubernetes" - clientset "k8s.io/client-go/kubernetes" - listersv1 "k8s.io/client-go/listers/core/v1" - "k8s.io/client-go/tools/cache" "k8s.io/klog/v2" ) @@ -28,9 +15,8 @@ func NewK8sDataStore(options ...K8sDatastoreOption) *K8sDatastore { store := &K8sDatastore{ poolMu: sync.RWMutex{}, InferenceModels: &sync.Map{}, + pods: &sync.Map{}, } - - store.podListerFactory = store.createPodLister for _, opt := range options { opt(store) } @@ -39,68 +25,29 @@ func NewK8sDataStore(options ...K8sDatastoreOption) *K8sDatastore { // The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) type K8sDatastore struct { - client kubernetes.Interface // poolMu is used to synchronize access to the inferencePool. - poolMu sync.RWMutex - inferencePool *v1alpha1.InferencePool - podListerFactory PodListerFactory - podLister *PodLister - InferenceModels *sync.Map + poolMu sync.RWMutex + inferencePool *v1alpha1.InferencePool + InferenceModels *sync.Map + pods *sync.Map } type K8sDatastoreOption func(*K8sDatastore) -type PodListerFactory func(*v1alpha1.InferencePool) *PodLister // WithPods can be used in tests to override the pods. -func WithPodListerFactory(factory PodListerFactory) K8sDatastoreOption { +func WithPods(pods []*PodMetrics) K8sDatastoreOption { return func(store *K8sDatastore) { - store.podListerFactory = factory + store.pods = &sync.Map{} + for _, pod := range pods { + store.pods.Store(pod.Pod, true) + } } } -type PodLister struct { - Lister listersv1.PodLister - sharedInformer informers.SharedInformerFactory -} - -func (l *PodLister) listEverything() ([]*corev1.Pod, error) { - return l.Lister.List(labels.Everything()) - -} - -func (ds *K8sDatastore) SetClient(client kubernetes.Interface) { - ds.client = client -} - func (ds *K8sDatastore) setInferencePool(pool *v1alpha1.InferencePool) { ds.poolMu.Lock() defer ds.poolMu.Unlock() - - if ds.inferencePool != nil && cmp.Equal(ds.inferencePool.Spec.Selector, pool.Spec.Selector) { - // Pool updated, but the selector stayed the same, so no need to change the informer. - ds.inferencePool = pool - return - } - - // New pool or selector updated. ds.inferencePool = pool - - if ds.podLister != nil && ds.podLister.sharedInformer != nil { - // Shutdown the old informer async since this takes a few seconds. - go func() { - ds.podLister.sharedInformer.Shutdown() - }() - } - - if ds.podListerFactory != nil { - // Create a new informer with the new selector. - ds.podLister = ds.podListerFactory(ds.inferencePool) - if ds.podLister != nil && ds.podLister.sharedInformer != nil { - ctx := context.Background() - ds.podLister.sharedInformer.Start(ctx.Done()) - ds.podLister.sharedInformer.WaitForCacheSync(ctx.Done()) - } - } } func (ds *K8sDatastore) getInferencePool() (*v1alpha1.InferencePool, error) { @@ -112,58 +59,13 @@ func (ds *K8sDatastore) getInferencePool() (*v1alpha1.InferencePool, error) { return ds.inferencePool, nil } -func (ds *K8sDatastore) createPodLister(pool *v1alpha1.InferencePool) *PodLister { - if ds.client == nil { - return nil - } - klog.V(logutil.DEFAULT).Infof("Creating informer for pool %v", pool.Name) - selectorSet := make(map[string]string) - for k, v := range pool.Spec.Selector { - selectorSet[string(k)] = string(v) - } - - newPodInformer := func(cs clientset.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { - informer := informersv1.NewFilteredPodInformer(cs, pool.Namespace, resyncPeriod, cache.Indexers{}, func(options *metav1.ListOptions) { - options.LabelSelector = labels.SelectorFromSet(selectorSet).String() - }) - err := informer.SetTransform(func(obj interface{}) (interface{}, error) { - // Remove unnecessary fields to improve memory footprint. - if accessor, err := meta.Accessor(obj); err == nil { - if accessor.GetManagedFields() != nil { - accessor.SetManagedFields(nil) - } - } - return obj, nil - }) - if err != nil { - klog.Errorf("Failed to set pod transformer: %v", err) - } - return informer - } - // 0 means we disable resyncing, it is not really useful to resync every hour (the controller-runtime default), - // if things go wrong in the watch, no one will wait for an hour for things to get fixed. - // As precedence, kube-scheduler also disables this since it is expensive to list all pods from the api-server regularly. - resyncPeriod := time.Duration(0) - sharedInformer := informers.NewSharedInformerFactory(ds.client, resyncPeriod) - sharedInformer.InformerFor(&v1.Pod{}, newPodInformer) - - return &PodLister{ - Lister: sharedInformer.Core().V1().Pods().Lister(), - sharedInformer: sharedInformer, - } -} - -func (ds *K8sDatastore) getPods() ([]*corev1.Pod, error) { - ds.poolMu.RLock() - defer ds.poolMu.RUnlock() - if !ds.HasSynced() { - return nil, errors.New("InferencePool is not initialized in datastore") - } - pods, err := ds.podLister.listEverything() - if err != nil { - return nil, err - } - return pods, nil +func (ds *K8sDatastore) GetPodIPs() []string { + var ips []string + ds.pods.Range(func(name, pod any) bool { + ips = append(ips, pod.(*corev1.Pod).Status.PodIP) + return true + }) + return ips } func (s *K8sDatastore) FetchModelData(modelName string) (returnModel *v1alpha1.InferenceModel) { diff --git a/pkg/ext-proc/backend/endpointslice_reconciler.go b/pkg/ext-proc/backend/endpointslice_reconciler.go new file mode 100644 index 00000000..a2a9790f --- /dev/null +++ b/pkg/ext-proc/backend/endpointslice_reconciler.go @@ -0,0 +1,109 @@ +package backend + +import ( + "context" + "strconv" + "time" + + "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + discoveryv1 "k8s.io/api/discovery/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + klog "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +var ( + serviceOwnerLabel = "kubernetes.io/service-name" +) + +type EndpointSliceReconciler struct { + client.Client + Scheme *runtime.Scheme + Record record.EventRecorder + ServiceName string + Zone string + Datastore *K8sDatastore +} + +func (c *EndpointSliceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + inferencePool, err := c.Datastore.getInferencePool() + if err != nil { + klog.V(logutil.DEFAULT).Infof("Skipping reconciling EndpointSlice because the InferencePool is not available yet: %v", err) + return ctrl.Result{Requeue: true, RequeueAfter: time.Second}, nil + } + + klog.V(logutil.DEFAULT).Info("Reconciling EndpointSlice ", req.NamespacedName) + + endpointSlice := &discoveryv1.EndpointSlice{} + if err := c.Get(ctx, req.NamespacedName, endpointSlice); err != nil { + klog.Errorf("Unable to get EndpointSlice: %v", err) + return ctrl.Result{}, err + } + c.updateDatastore(endpointSlice, inferencePool) + + return ctrl.Result{}, nil +} + +// TODO: Support multiple endpointslices for a single service +func (c *EndpointSliceReconciler) updateDatastore( + slice *discoveryv1.EndpointSlice, + inferencePool *v1alpha1.InferencePool) { + podMap := make(map[Pod]bool) + + for _, endpoint := range slice.Endpoints { + klog.V(logutil.DEFAULT).Infof("Zone: %v \n endpoint: %+v \n", c.Zone, endpoint) + if c.validPod(endpoint) { + pod := Pod{ + Name: endpoint.TargetRef.Name, + Address: endpoint.Addresses[0] + ":" + strconv.Itoa(int(inferencePool.Spec.TargetPortNumber)), + } + podMap[pod] = true + klog.V(logutil.DEFAULT).Infof("Storing pod %v", pod) + c.Datastore.pods.Store(pod, true) + } + } + + removeOldPods := func(k, v any) bool { + pod, ok := k.(Pod) + if !ok { + klog.Errorf("Unable to cast key to Pod: %v", k) + return false + } + if _, ok := podMap[pod]; !ok { + klog.V(logutil.DEFAULT).Infof("Removing pod %v", pod) + c.Datastore.pods.Delete(pod) + } + return true + } + c.Datastore.pods.Range(removeOldPods) +} + +func (c *EndpointSliceReconciler) SetupWithManager(mgr ctrl.Manager) error { + ownsEndPointSlice := func(object client.Object) bool { + // Check if the object is an EndpointSlice + endpointSlice, ok := object.(*discoveryv1.EndpointSlice) + if !ok { + return false + } + + gotLabel := endpointSlice.ObjectMeta.Labels[serviceOwnerLabel] + wantLabel := c.ServiceName + return gotLabel == wantLabel + } + + return ctrl.NewControllerManagedBy(mgr). + For(&discoveryv1.EndpointSlice{}, + builder.WithPredicates(predicate.NewPredicateFuncs(ownsEndPointSlice))). + Complete(c) +} + +func (c *EndpointSliceReconciler) validPod(endpoint discoveryv1.Endpoint) bool { + validZone := c.Zone == "" || c.Zone != "" && *endpoint.Zone == c.Zone + return validZone && *endpoint.Conditions.Ready + +} diff --git a/pkg/ext-proc/backend/endpointslice_reconcilier_test.go b/pkg/ext-proc/backend/endpointslice_reconcilier_test.go new file mode 100644 index 00000000..e3c927ba --- /dev/null +++ b/pkg/ext-proc/backend/endpointslice_reconcilier_test.go @@ -0,0 +1,202 @@ +package backend + +import ( + "sync" + "testing" + + "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" + v1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" +) + +var ( + basePod1 = Pod{Name: "pod1"} + basePod2 = Pod{Name: "pod2"} + basePod3 = Pod{Name: "pod3"} +) + +func TestUpdateDatastore_EndpointSliceReconciler(t *testing.T) { + tests := []struct { + name string + datastore *K8sDatastore + incomingSlice *discoveryv1.EndpointSlice + wantPods *sync.Map + }{ + { + name: "Add new pod", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + }, + }, + }, + incomingSlice: &discoveryv1.EndpointSlice{ + Endpoints: []discoveryv1.Endpoint{ + { + TargetRef: &v1.ObjectReference{ + Name: "pod1", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + { + TargetRef: &v1.ObjectReference{ + Name: "pod2", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + { + TargetRef: &v1.ObjectReference{ + Name: "pod3", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + }, + }, + wantPods: populateMap(basePod1, basePod2, basePod3), + }, + { + name: "New pod, but its not ready yet. Do not add.", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + }, + }, + }, + incomingSlice: &discoveryv1.EndpointSlice{ + Endpoints: []discoveryv1.Endpoint{ + { + TargetRef: &v1.ObjectReference{ + Name: "pod1", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + { + TargetRef: &v1.ObjectReference{ + Name: "pod2", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + { + TargetRef: &v1.ObjectReference{ + Name: "pod3", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: new(bool), + }, + Addresses: []string{"0.0.0.0"}, + }, + }, + }, + wantPods: populateMap(basePod1, basePod2), + }, + { + name: "Existing pod not ready, new pod added, and is ready", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + }, + }, + }, + incomingSlice: &discoveryv1.EndpointSlice{ + Endpoints: []discoveryv1.Endpoint{ + { + TargetRef: &v1.ObjectReference{ + Name: "pod1", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: new(bool), + }, + Addresses: []string{"0.0.0.0"}, + }, + { + TargetRef: &v1.ObjectReference{ + Name: "pod2", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + { + TargetRef: &v1.ObjectReference{ + Name: "pod3", + }, + Zone: new(string), + Conditions: discoveryv1.EndpointConditions{ + Ready: truePointer(), + }, + Addresses: []string{"0.0.0.0"}, + }, + }, + }, + wantPods: populateMap(basePod3, basePod2), + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + endpointSliceReconciler := &EndpointSliceReconciler{Datastore: test.datastore, Zone: ""} + endpointSliceReconciler.updateDatastore(test.incomingSlice, test.datastore.inferencePool) + + if mapsEqual(endpointSliceReconciler.Datastore.pods, test.wantPods) { + t.Errorf("Unexpected output pod mismatch. \n Got %v \n Want: %v \n", + endpointSliceReconciler.Datastore.pods, + test.wantPods) + } + }) + } +} + +func mapsEqual(map1, map2 *sync.Map) bool { + equal := true + + map1.Range(func(k, v any) bool { + if _, ok := map2.Load(k); !ok { + equal = false + return false + } + return true + }) + map2.Range(func(k, v any) bool { + if _, ok := map1.Load(k); !ok { + equal = false + return false + } + return true + }) + + return equal +} + +func truePointer() *bool { + primitivePointersAreSilly := true + return &primitivePointersAreSilly +} diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index 63f20db6..c4545497 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -8,16 +8,16 @@ import ( ) type FakePodMetricsClient struct { - Err map[string]error - Res map[string]*PodMetrics + Err map[Pod]error + Res map[Pod]*PodMetrics } func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod Pod, existing *PodMetrics) (*PodMetrics, error) { - if err, ok := f.Err[pod.Name]; ok { + if err, ok := f.Err[pod]; ok { return nil, err } - klog.V(1).Infof("pod: %+v\n existing: %+v \n new: %+v \n", pod, existing, f.Res[pod.Name]) - return f.Res[pod.Name], nil + klog.V(1).Infof("pod: %+v\n existing: %+v \n new: %+v \n", pod, existing, f.Res[pod]) + return f.Res[pod], nil } type FakeDataStore struct { diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index 117766b9..5609ca53 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -146,24 +146,3 @@ func populateServiceMap(services ...*v1alpha1.InferenceModel) *sync.Map { } return returnVal } - -func mapsEqual(map1, map2 *sync.Map) bool { - equal := true - - map1.Range(func(k, v any) bool { - if _, ok := map2.Load(k); !ok { - equal = false - return false - } - return true - }) - map2.Range(func(k, v any) bool { - if _, ok := map1.Load(k); !ok { - equal = false - return false - } - return true - }) - - return equal -} diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/backend/inferencepool_reconciler.go index 0c2ae75f..35a41f8f 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler.go @@ -21,6 +21,7 @@ type InferencePoolReconciler struct { Record record.EventRecorder PoolNamespacedName types.NamespacedName Datastore *K8sDatastore + Zone string } func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index d6ccf85f..8bf67257 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -3,14 +3,11 @@ package backend import ( "context" "fmt" - "math/rand" - "strconv" "sync" "time" "go.uber.org/multierr" logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" - corev1 "k8s.io/api/core/v1" klog "k8s.io/klog/v2" ) @@ -29,8 +26,7 @@ func NewProvider(pmc PodMetricsClient, datastore *K8sDatastore) *Provider { // Provider provides backend pods and information such as metrics. type Provider struct { - // key: PodName, value: *PodMetrics - // TODO: change to use NamespacedName once we support multi-tenant inferencePools + // key: Pod, value: *PodMetrics podMetrics sync.Map pmc PodMetricsClient datastore *K8sDatastore @@ -51,11 +47,11 @@ func (p *Provider) AllPodMetrics() []*PodMetrics { } func (p *Provider) UpdatePodMetrics(pod Pod, pm *PodMetrics) { - p.podMetrics.Store(pod.Name, pm) + p.podMetrics.Store(pod, pm) } func (p *Provider) GetPodMetrics(pod Pod) (*PodMetrics, bool) { - val, ok := p.podMetrics.Load(pod.Name) + val, ok := p.podMetrics.Load(pod) if ok { return val.(*PodMetrics), true } @@ -105,70 +101,31 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval time.Duratio // refreshPodsOnce lists pods and updates keys in the podMetrics map. // Note this function doesn't update the PodMetrics value, it's done separately. func (p *Provider) refreshPodsOnce() { - pods, err := p.datastore.getPods() - if err != nil { - klog.V(logutil.DEFAULT).Infof("Couldn't list pods: %v", err) - p.podMetrics.Clear() - return - } - pool, _ := p.datastore.getInferencePool() - // revision is used to track which entries we need to remove in the next iteration that removes - // metrics for pods that don't exist anymore. Otherwise we have to build a map of the listed pods, - // which is not efficient. Revision can be any random id as long as it is different from the last - // refresh, so it should be very reliable (as reliable as the probability of randomly picking two - // different numbers from range 0 - maxInt). - revision := rand.Int() - ready := 0 - for _, pod := range pods { - if !podIsReady(pod) { - continue - } - // a ready pod - ready++ - if val, ok := p.podMetrics.Load(pod.Name); ok { - // pod already exists - pm := val.(*PodMetrics) - pm.revision = revision - continue - } - // new pod, add to the store for probing - new := &PodMetrics{ - Pod: Pod{ - Name: pod.Name, - Address: pod.Status.PodIP + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)), - }, - Metrics: Metrics{ - ActiveModels: make(map[string]int), - }, - revision: revision, + // merge new pods with cached ones. + // add new pod to the map + addNewPods := func(k, v any) bool { + pod := k.(Pod) + if _, ok := p.podMetrics.Load(pod); !ok { + new := &PodMetrics{ + Pod: pod, + Metrics: Metrics{ + ActiveModels: make(map[string]int), + }, + } + p.podMetrics.Store(pod, new) } - p.podMetrics.Store(pod.Name, new) + return true } - - klog.V(logutil.DEFAULT).Infof("Pods in pool %s/%s with selector %v: total=%v ready=%v", - pool.Namespace, pool.Name, pool.Spec.Selector, len(pods), ready) - // remove pods that don't exist any more. mergeFn := func(k, v any) bool { - pm := v.(*PodMetrics) - if pm.revision != revision { - p.podMetrics.Delete(pm.Pod.Name) + pod := k.(Pod) + if _, ok := p.datastore.pods.Load(pod); !ok { + p.podMetrics.Delete(pod) } return true } p.podMetrics.Range(mergeFn) -} - -func podIsReady(pod *corev1.Pod) bool { - if pod.DeletionTimestamp != nil { - return false - } - for _, condition := range pod.Status.Conditions { - if condition.Type == corev1.PodReady { - return condition.Status == corev1.ConditionTrue - } - } - return false + p.datastore.pods.Range(addNewPods) } func (p *Provider) refreshMetricsOnce() error { @@ -184,8 +141,8 @@ func (p *Provider) refreshMetricsOnce() error { errCh := make(chan error) processOnePod := func(key, value any) bool { klog.V(logutil.TRACE).Infof("Processing pod %v and metric %v", key, value) + pod := key.(Pod) existing := value.(*PodMetrics) - pod := existing.Pod wg.Add(1) go func() { defer wg.Done() diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/ext-proc/backend/provider_test.go index 9159ba48..ad231f57 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/ext-proc/backend/provider_test.go @@ -2,18 +2,17 @@ package backend import ( "errors" + "sync" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - testingutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" - corev1 "k8s.io/api/core/v1" ) var ( pod1 = &PodMetrics{ - Pod: Pod{Name: "pod1", Address: "address1:9009"}, + Pod: Pod{Name: "pod1"}, Metrics: Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -25,7 +24,7 @@ var ( }, } pod2 = &PodMetrics{ - Pod: Pod{Name: "pod2", Address: "address2:9009"}, + Pod: Pod{Name: "pod2"}, Metrics: Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.2, @@ -39,67 +38,44 @@ var ( ) func TestProvider(t *testing.T) { - allPodsLister := &testingutil.FakePodLister{ - PodsList: []*corev1.Pod{ - testingutil.MakePod(pod1.Pod.Name).SetReady().SetPodIP("address1").Obj(), - testingutil.MakePod(pod2.Pod.Name).SetReady().SetPodIP("address2").Obj(), - }, - } - allPodsMetricsClient := &FakePodMetricsClient{ - Res: map[string]*PodMetrics{ - pod1.Pod.Name: pod1, - pod2.Pod.Name: pod2, - }, - } - tests := []struct { - name string - initPodMetrics []*PodMetrics - lister *testingutil.FakePodLister - pmc PodMetricsClient - step func(*Provider) - want []*PodMetrics + name string + pmc PodMetricsClient + datastore *K8sDatastore + initErr bool + want []*PodMetrics }{ { - name: "Init without refreshing pods", - initPodMetrics: []*PodMetrics{pod1, pod2}, - lister: allPodsLister, - pmc: allPodsMetricsClient, - step: func(p *Provider) { - _ = p.refreshMetricsOnce() + name: "Init success", + datastore: &K8sDatastore{ + pods: populateMap(pod1.Pod, pod2.Pod), }, - want: []*PodMetrics{pod1, pod2}, - }, - { - name: "Fetching all success", - lister: allPodsLister, - pmc: allPodsMetricsClient, - step: func(p *Provider) { - p.refreshPodsOnce() - _ = p.refreshMetricsOnce() + pmc: &FakePodMetricsClient{ + Res: map[Pod]*PodMetrics{ + pod1.Pod: pod1, + pod2.Pod: pod2, + }, }, want: []*PodMetrics{pod1, pod2}, }, { - name: "Fetch metrics error", - lister: allPodsLister, + name: "Fetch metrics error", pmc: &FakePodMetricsClient{ - Err: map[string]error{ - pod2.Pod.Name: errors.New("injected error"), + Err: map[Pod]error{ + pod2.Pod: errors.New("injected error"), }, - Res: map[string]*PodMetrics{ - pod1.Pod.Name: pod1, + Res: map[Pod]*PodMetrics{ + pod1.Pod: pod1, }, }, - step: func(p *Provider) { - p.refreshPodsOnce() - _ = p.refreshMetricsOnce() + datastore: &K8sDatastore{ + pods: populateMap(pod1.Pod, pod2.Pod), }, want: []*PodMetrics{ pod1, // Failed to fetch pod2 metrics so it remains the default values. { - Pod: pod2.Pod, + Pod: Pod{Name: "pod2"}, Metrics: Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, @@ -109,73 +85,30 @@ func TestProvider(t *testing.T) { }, }, }, - { - name: "A new pod added", - initPodMetrics: []*PodMetrics{pod2}, - lister: allPodsLister, - pmc: allPodsMetricsClient, - step: func(p *Provider) { - p.refreshPodsOnce() - _ = p.refreshMetricsOnce() - }, - want: []*PodMetrics{pod1, pod2}, - }, - { - name: "A pod removed", - initPodMetrics: []*PodMetrics{pod1, pod2}, - lister: &testingutil.FakePodLister{ - PodsList: []*corev1.Pod{ - testingutil.MakePod(pod2.Pod.Name).SetReady().SetPodIP("address2").Obj(), - }, - }, - pmc: allPodsMetricsClient, - step: func(p *Provider) { - p.refreshPodsOnce() - _ = p.refreshMetricsOnce() - }, - want: []*PodMetrics{pod2}, - }, - { - name: "A pod removed, another added", - initPodMetrics: []*PodMetrics{pod1}, - lister: &testingutil.FakePodLister{ - PodsList: []*corev1.Pod{ - testingutil.MakePod(pod1.Pod.Name).SetReady().SetPodIP("address1").Obj(), - }, - }, - pmc: allPodsMetricsClient, - step: func(p *Provider) { - p.refreshPodsOnce() - _ = p.refreshMetricsOnce() - }, - want: []*PodMetrics{pod1}, - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - datastore := NewK8sDataStore(WithPodListerFactory( - func(pool *v1alpha1.InferencePool) *PodLister { - return &PodLister{ - Lister: test.lister, - } - })) - datastore.setInferencePool(&v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{TargetPortNumber: 9009}, - }) - p := NewProvider(test.pmc, datastore) - for _, m := range test.initPodMetrics { - p.UpdatePodMetrics(m.Pod, m) + p := NewProvider(test.pmc, test.datastore) + err := p.Init(time.Millisecond, time.Millisecond) + if test.initErr != (err != nil) { + t.Fatalf("Unexpected error, got: %v, want: %v", err, test.initErr) } - test.step(p) metrics := p.AllPodMetrics() lessFunc := func(a, b *PodMetrics) bool { return a.String() < b.String() } - if diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(lessFunc), - cmpopts.IgnoreFields(PodMetrics{}, "revision")); diff != "" { + if diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(lessFunc)); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) } } + +func populateMap(pods ...Pod) *sync.Map { + newMap := &sync.Map{} + for _, pod := range pods { + newMap.Store(pod, true) + } + return newMap +} diff --git a/pkg/ext-proc/backend/types.go b/pkg/ext-proc/backend/types.go index d375e4ec..7e399fed 100644 --- a/pkg/ext-proc/backend/types.go +++ b/pkg/ext-proc/backend/types.go @@ -28,7 +28,6 @@ type Metrics struct { type PodMetrics struct { Pod Metrics - revision int } func (pm *PodMetrics) String() string { diff --git a/pkg/ext-proc/health.go b/pkg/ext-proc/health.go index 62527d06..488851eb 100644 --- a/pkg/ext-proc/health.go +++ b/pkg/ext-proc/health.go @@ -7,7 +7,6 @@ import ( healthPb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/status" "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" ) @@ -20,7 +19,7 @@ func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckReques klog.Infof("gRPC health check not serving: %s", in.String()) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil } - klog.V(logutil.DEBUG).Infof("gRPC health check serving: %s", in.String()) + klog.Infof("gRPC health check serving: %s", in.String()) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil } diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 98b7e6ca..a783aa2c 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -18,7 +18,6 @@ import ( runserver "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/component-base/metrics/legacyregistry" @@ -54,6 +53,14 @@ var ( "poolNamespace", runserver.DefaultPoolNamespace, "Namespace of the InferencePool this Endpoint Picker is associated with.") + serviceName = flag.String( + "serviceName", + runserver.DefaultServiceName, + "Name of the Service that will be used to read EndpointSlices from") + zone = flag.String( + "zone", + runserver.DefaultZone, + "The zone that this instance is created in. Will be passed to the corresponding endpointSlice. ") refreshPodsInterval = flag.Duration( "refreshPodsInterval", runserver.DefaultRefreshPodsInterval, @@ -99,6 +106,8 @@ func main() { TargetEndpointKey: *targetEndpointKey, PoolName: *poolName, PoolNamespace: *poolNamespace, + ServiceName: *serviceName, + Zone: *zone, RefreshPodsInterval: *refreshPodsInterval, RefreshMetricsInterval: *refreshMetricsInterval, Scheme: scheme, @@ -107,15 +116,12 @@ func main() { } serverRunner.Setup() - k8sClient, err := kubernetes.NewForConfigAndClient(cfg, serverRunner.Manager.GetHTTPClient()) - if err != nil { - klog.Fatalf("Failed to create client: %v", err) - } - datastore.SetClient(k8sClient) - // Start health and ext-proc servers in goroutines healthSvr := startHealthServer(datastore, *grpcHealthPort) - extProcSvr := serverRunner.Start(&vllm.PodMetricsClientImpl{}) + extProcSvr := serverRunner.Start( + datastore, + &vllm.PodMetricsClientImpl{}, + ) // Start metrics handler metricsSvr := startMetricsHandler(*metricsPort, cfg) @@ -210,5 +216,9 @@ func validateFlags() error { return fmt.Errorf("required %q flag not set", "poolName") } + if *serviceName == "" { + return fmt.Errorf("required %q flag not set", "serviceName") + } + return nil } diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/ext-proc/scheduling/filter_test.go index 34731d15..d88f437c 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/ext-proc/scheduling/filter_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" ) @@ -207,7 +206,7 @@ func TestFilter(t *testing.T) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got, cmpopts.IgnoreFields(backend.PodMetrics{}, "revision")); diff != "" { + if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -401,7 +400,7 @@ func TestFilterFunc(t *testing.T) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got, cmpopts.IgnoreFields(backend.PodMetrics{}, "revision")); diff != "" { + if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index 981dab11..1c9c1b2e 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -23,12 +23,14 @@ type ExtProcServerRunner struct { TargetEndpointKey string PoolName string PoolNamespace string + ServiceName string + Zone string RefreshPodsInterval time.Duration RefreshMetricsInterval time.Duration Scheme *runtime.Scheme Config *rest.Config Datastore *backend.K8sDatastore - Manager ctrl.Manager + manager ctrl.Manager } // Default values for CLI flags in main @@ -37,6 +39,8 @@ const ( DefaultTargetEndpointKey = "x-gateway-destination-endpoint" // default for --targetEndpointKey DefaultPoolName = "" // required but no default DefaultPoolNamespace = "default" // default for --poolNamespace + DefaultServiceName = "" // required but no default + DefaultZone = "" // default for --zone DefaultRefreshPodsInterval = 10 * time.Second // default for --refreshPodsInterval DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval ) @@ -47,20 +51,22 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { TargetEndpointKey: DefaultTargetEndpointKey, PoolName: DefaultPoolName, PoolNamespace: DefaultPoolNamespace, + ServiceName: DefaultServiceName, + Zone: DefaultZone, RefreshPodsInterval: DefaultRefreshPodsInterval, RefreshMetricsInterval: DefaultRefreshMetricsInterval, // Scheme, Config, and Datastore can be assigned later. } } -// Setup creates the reconcilers for pools and models and starts the manager. +// Setup creates the reconcilers for pools, models, and endpointSlices and starts the manager. func (r *ExtProcServerRunner) Setup() { // Create a new manager to manage controllers mgr, err := ctrl.NewManager(r.Config, ctrl.Options{Scheme: r.Scheme}) if err != nil { klog.Fatalf("Failed to create controller manager: %v", err) } - r.Manager = mgr + r.manager = mgr // Create the controllers and register them with the manager if err := (&backend.InferencePoolReconciler{ @@ -88,10 +94,22 @@ func (r *ExtProcServerRunner) Setup() { }).SetupWithManager(mgr); err != nil { klog.Fatalf("Failed setting up InferenceModelReconciler: %v", err) } + + if err := (&backend.EndpointSliceReconciler{ + Datastore: r.Datastore, + Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Record: mgr.GetEventRecorderFor("endpointslice"), + ServiceName: r.ServiceName, + Zone: r.Zone, + }).SetupWithManager(mgr); err != nil { + klog.Fatalf("Failed setting up EndpointSliceReconciler: %v", err) + } } // Start starts the Envoy external processor server in a goroutine. func (r *ExtProcServerRunner) Start( + podDatastore *backend.K8sDatastore, podMetricsClient backend.PodMetricsClient, ) *grpc.Server { svr := grpc.NewServer() @@ -104,7 +122,7 @@ func (r *ExtProcServerRunner) Start( klog.Infof("Ext-proc server listening on port: %d", r.GrpcPort) // Initialize backend provider - pp := backend.NewProvider(podMetricsClient, r.Datastore) + pp := backend.NewProvider(podMetricsClient, podDatastore) if err := pp.Init(r.RefreshPodsInterval, r.RefreshMetricsInterval); err != nil { klog.Fatalf("Failed to initialize backend provider: %v", err) } @@ -125,12 +143,13 @@ func (r *ExtProcServerRunner) Start( } func (r *ExtProcServerRunner) StartManager() { - if r.Manager == nil { + if r.manager == nil { klog.Fatalf("Runner has no manager setup to run: %v", r) } // Start the controller manager. Blocking and will return when shutdown is complete. klog.Infof("Starting controller manager") - if err := r.Manager.Start(ctrl.SetupSignalHandler()); err != nil { + mgr := r.manager + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { klog.Fatalf("Error starting controller manager: %v", err) } klog.Info("Controller manager shutting down") diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index a9dc4efa..63972849 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -18,13 +18,13 @@ import ( func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) *grpc.Server { ps := make(backend.PodSet) - pms := make(map[string]*backend.PodMetrics) + pms := make(map[backend.Pod]*backend.PodMetrics) for _, pod := range pods { ps[pod.Pod] = true - pms[pod.Pod.Name] = pod + pms[pod.Pod] = pod } pmc := &backend.FakePodMetricsClient{Res: pms} - pp := backend.NewProvider(pmc, backend.NewK8sDataStore()) + pp := backend.NewProvider(pmc, backend.NewK8sDataStore(backend.WithPods(pods))) if err := pp.Init(refreshPodsInterval, refreshMetricsInterval); err != nil { klog.Fatalf("failed to initialize: %v", err) } diff --git a/pkg/ext-proc/util/testing/lister.go b/pkg/ext-proc/util/testing/lister.go deleted file mode 100644 index 023f30a1..00000000 --- a/pkg/ext-proc/util/testing/lister.go +++ /dev/null @@ -1,19 +0,0 @@ -package testing - -import ( - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/labels" - listersv1 "k8s.io/client-go/listers/core/v1" -) - -type FakePodLister struct { - PodsList []*v1.Pod -} - -func (l *FakePodLister) List(selector labels.Selector) (ret []*v1.Pod, err error) { - return l.PodsList, nil -} - -func (l *FakePodLister) Pods(namespace string) listersv1.PodNamespaceLister { - panic("not implemented") -} diff --git a/pkg/ext-proc/util/testing/wrappers.go b/pkg/ext-proc/util/testing/wrappers.go deleted file mode 100644 index 7b593bbd..00000000 --- a/pkg/ext-proc/util/testing/wrappers.go +++ /dev/null @@ -1,38 +0,0 @@ -package testing - -import ( - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// PodWrapper wraps a Pod inside. -type PodWrapper struct{ corev1.Pod } - -// MakePod creates a Pod wrapper. -func MakePod(name string) *PodWrapper { - return &PodWrapper{ - corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - }, - } -} - -// Obj returns the inner Pod. -func (p *PodWrapper) Obj() *corev1.Pod { - return &p.Pod -} - -func (p *PodWrapper) SetReady() *PodWrapper { - p.Status.Conditions = []corev1.PodCondition{{ - Type: corev1.PodReady, - Status: corev1.ConditionTrue, - }} - return p -} - -func (p *PodWrapper) SetPodIP(podIP string) *PodWrapper { - p.Status.PodIP = podIP - return p -} diff --git a/pkg/manifests/ext_proc.yaml b/pkg/manifests/ext_proc.yaml index b9b860dc..4e82779e 100644 --- a/pkg/manifests/ext_proc.yaml +++ b/pkg/manifests/ext_proc.yaml @@ -77,6 +77,8 @@ spec: - "vllm-llama2-7b-pool" - -v - "3" + - -serviceName + - "vllm-llama2-7b-pool" - -grpcPort - "9002" - -grpcHealthPort diff --git a/pkg/manifests/vllm/deployment.yaml b/pkg/manifests/vllm/deployment.yaml index 1f5073e9..4af0891d 100644 --- a/pkg/manifests/vllm/deployment.yaml +++ b/pkg/manifests/vllm/deployment.yaml @@ -1,3 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: vllm-llama2-7b-pool +spec: + selector: + app: vllm-llama2-7b-pool + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 + type: ClusterIP +--- apiVersion: apps/v1 kind: Deployment metadata: diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 019e858a..c2c1ea92 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -245,6 +245,11 @@ func createModelServer(k8sClient client.Client, secretPath, deployPath string) { // Wait for the deployment to be available. testutils.DeploymentAvailable(ctx, k8sClient, deploy, modelReadyTimeout, interval) + + // Wait for the service to exist. + testutils.EventuallyExists(ctx, func() error { + return k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: modelServerName}, &corev1.Service{}) + }, existsTimeout, interval) } // createEnvoy creates the envoy proxy resources used for testing from the given filePath. diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 3dfe28f7..95ad4908 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -12,7 +12,6 @@ import ( "log" "os" "path/filepath" - "strconv" "testing" "time" @@ -27,8 +26,6 @@ import ( "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" runserver "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" extprocutils "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" - testingutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8syaml "k8s.io/apimachinery/pkg/util/yaml" @@ -116,7 +113,7 @@ func SKIPTestHandleRequestBody(t *testing.T) { { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("pod-1:8000"), + RawValue: []byte("address-1"), }, }, { @@ -182,7 +179,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("pod-1:8000"), + RawValue: []byte("address-1"), }, }, { @@ -196,7 +193,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { Fields: map[string]*structpb.Value{ runserver.DefaultTargetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: "pod-1:8000", + StringValue: "address-1", }, }, }, @@ -206,38 +203,47 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, } - metrics := []*backend.Metrics{ + pods := []*backend.PodMetrics{ { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, + Pod: extprocutils.FakePod(0), + Metrics: backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, }, }, { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.1, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg2": 1, + Pod: extprocutils.FakePod(1), + Metrics: backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, + }, }, }, { - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, + Pod: extprocutils.FakePod(2), + Metrics: backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + }, }, }, } // Set up global k8sclient and extproc server runner with test environment config - podMetrics := BeforeSuit(metrics) + BeforeSuit() for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, podMetrics) + client, cleanup := setUpHermeticServer(t, pods) t.Cleanup(cleanup) want := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestBody{ @@ -318,8 +324,8 @@ func setUpHermeticServer(t *testing.T, pods []*backend.PodMetrics) (client extPr } } } - inferencePool := &v1alpha1.InferencePool{} for _, doc := range docs { + inferencePool := &v1alpha1.InferencePool{} if err = yaml.Unmarshal(doc, inferencePool); err != nil { log.Fatalf("Can't unmarshal object: %v", doc) } @@ -328,19 +334,18 @@ func setUpHermeticServer(t *testing.T, pods []*backend.PodMetrics) (client extPr if err := k8sClient.Create(context.Background(), inferencePool); err != nil { log.Fatalf("unable to create inferencePool %v: %v", inferencePool.Name, err) } - // expecting a single inferencepool - break } } ps := make(backend.PodSet) - pms := make(map[string]*backend.PodMetrics) + pms := make(map[backend.Pod]*backend.PodMetrics) for _, pod := range pods { ps[pod.Pod] = true - pms[pod.Pod.Name] = pod + pms[pod.Pod] = pod } pmc := &backend.FakePodMetricsClient{Res: pms} - server := serverRunner.Start(pmc) + + server := serverRunner.Start(backend.NewK8sDataStore(backend.WithPods(pods)), pmc) if err != nil { log.Fatalf("Ext-proc failed with the err: %v", err) } @@ -368,7 +373,7 @@ func setUpHermeticServer(t *testing.T, pods []*backend.PodMetrics) (client extPr } // Sets up a test environment and returns the runner struct -func BeforeSuit(metrics []*backend.Metrics) []*backend.PodMetrics { +func BeforeSuit() { // Set up mock k8s API Client testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, @@ -390,35 +395,12 @@ func BeforeSuit(metrics []*backend.Metrics) []*backend.PodMetrics { log.Fatalf("No error, but returned kubernetes client is nil, cfg: %v", cfg) } - podMetrics := []*backend.PodMetrics{} - fakeLister := &testingutil.FakePodLister{ - PodsList: []*corev1.Pod{}, - } - for i, m := range metrics { - podName := "pod-" + strconv.Itoa(i) - pod := testingutil.MakePod(podName).SetReady().SetPodIP(podName).Obj() - fakeLister.PodsList = append(fakeLister.PodsList, pod) - podMetrics = append(podMetrics, &backend.PodMetrics{ - Pod: backend.Pod{ - Name: pod.Name, - Address: pod.Status.PodIP + ":8000", - }, - Metrics: *m, - }) - } - serverRunner = runserver.NewDefaultExtProcServerRunner() // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" serverRunner.Scheme = scheme serverRunner.Config = cfg - serverRunner.Datastore = backend.NewK8sDataStore(backend.WithPodListerFactory( - func(pool *v1alpha1.InferencePool) *backend.PodLister { - klog.V(1).Infof("Setting the fake lister %v", len(fakeLister.PodsList)) - return &backend.PodLister{ - Lister: fakeLister, - } - })) + serverRunner.Datastore = backend.NewK8sDataStore() serverRunner.Setup() @@ -426,10 +408,6 @@ func BeforeSuit(metrics []*backend.Metrics) []*backend.PodMetrics { go func() { serverRunner.StartManager() }() - - // Wait the reconcilers to populate the datastore. - time.Sleep(5 * time.Second) - return podMetrics } func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { From ce2785c3c7a71270603ff07225ba8b2f1e14e063 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:51:55 -0700 Subject: [PATCH 002/260] Fixing small linter complaints (#302) --- api/v1alpha1/inferencemodel_types.go | 8 ++++---- api/v1alpha1/inferencepool_types.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/v1alpha1/inferencemodel_types.go b/api/v1alpha1/inferencemodel_types.go index 3661820d..f171c10e 100644 --- a/api/v1alpha1/inferencemodel_types.go +++ b/api/v1alpha1/inferencemodel_types.go @@ -202,7 +202,7 @@ type InferenceModelConditionType string type InferenceModelConditionReason string const ( - // This condition indicates if the model config is accepted, and if not, why. + // ModelConditionAccepted indicates if the model config is accepted, and if not, why. // // Possible reasons for this condition to be True are: // @@ -218,14 +218,14 @@ const ( // ModelConditionAccepted InferenceModelConditionType = "Accepted" - // Desired state. Model conforms to the state of the pool. + // ModelReasonAccepted is the desired state. Model conforms to the state of the pool. ModelReasonAccepted InferenceModelConditionReason = "Accepted" - // This reason is used when a given ModelName already exists within the pool. + // ModelReasonNameInUse is used when a given ModelName already exists within the pool. // Details about naming conflict resolution are on the ModelName field itself. ModelReasonNameInUse InferenceModelConditionReason = "ModelNameInUse" - // This reason is the initial state, and indicates that the controller has not yet reconciled the InferenceModel. + // ModelReasonPending is the initial state, and indicates that the controller has not yet reconciled the InferenceModel. ModelReasonPending InferenceModelConditionReason = "Pending" ) diff --git a/api/v1alpha1/inferencepool_types.go b/api/v1alpha1/inferencepool_types.go index 61a3764d..b4c95d40 100644 --- a/api/v1alpha1/inferencepool_types.go +++ b/api/v1alpha1/inferencepool_types.go @@ -207,7 +207,7 @@ type InferencePoolConditionType string type InferencePoolConditionReason string const ( - // This condition indicates if the pool is ready to accept traffic, and if not, why. + // PoolConditionReady indicates if the pool is ready to accept traffic, and if not, why. // // Possible reasons for this condition to be True are: // @@ -223,13 +223,13 @@ const ( // PoolConditionReady InferencePoolConditionType = "Ready" - // Desired state. The pool and its components are initialized and ready for traffic. + // PoolReasonReady is the desired state. The pool and its components are initialized and ready for traffic. PoolReasonReady InferencePoolConditionReason = "Ready" - // This reason is used when the EPP has not yet passed health checks, or has started failing them. + // PoolReasonEPPNotHealthy is used when the EPP has not yet passed health checks, or has started failing them. PoolReasonEPPNotHealthy InferencePoolConditionReason = "EndpointPickerNotHealthy" - // This reason is the initial state, and indicates that the controller has not yet reconciled this pool. + // PoolReasonPending is the initial state, and indicates that the controller has not yet reconciled this pool. PoolReasonPending InferencePoolConditionReason = "Pending" ) From 3ff0af85f9341468c6f4b1e2923dd8f9b413b7e2 Mon Sep 17 00:00:00 2001 From: BenjaminBraunDev Date: Thu, 6 Feb 2025 16:19:55 -0800 Subject: [PATCH 003/260] In hermetic test, add additional test cases and move k8sClient object creation so it's called once for all tests (#278) * Add 2 new test cases to hermetic integration test. Move k8sclient API setup to BeforeSuit() so it is set up once for all test cases. Add getter function to scheduling to reference queue threshold for lora affinity inside integration tests. * remove vestigial unit test from hermetic test, minor change to comments, remove unreachable error check. * Add test-case for sheddable that is not shed, fix nits and rename the non-lora test case to use a different model name. * Fix small typo. --- test/integration/hermetic_test.go | 436 +++++++++++------- .../inferencepool-with-model-hermetic.yaml | 27 ++ 2 files changed, 296 insertions(+), 167 deletions(-) diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 95ad4908..b52cc9d7 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -17,6 +17,7 @@ import ( configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -47,32 +48,73 @@ var ( scheme = runtime.NewScheme() ) -func SKIPTestHandleRequestBody(t *testing.T) { +func TestKubeInferenceModelRequest(t *testing.T) { tests := []struct { - name string - req *extProcPb.ProcessingRequest - pods []*backend.PodMetrics - models map[string]*v1alpha1.InferenceModel - wantHeaders []*configPb.HeaderValueOption - wantBody []byte - wantErr bool + name string + req *extProcPb.ProcessingRequest + pods []*backend.PodMetrics + wantHeaders []*configPb.HeaderValueOption + wantMetadata *structpb.Struct + wantBody []byte + wantErr bool + immediateResponse *extProcPb.ImmediateResponse }{ { - name: "success", + name: "select lower queue and kv cache, no active lora", req: extprocutils.GenerateRequest("my-model"), - models: map[string]*v1alpha1.InferenceModel{ - "my-model": { - Spec: v1alpha1.InferenceModelSpec{ - ModelName: "my-model", - TargetModels: []v1alpha1.TargetModel{ - { - Name: "my-model-v1", - Weight: pointer(100), - }, + // pod-1 will be picked because it has relatively low queue size and low KV cache. + pods: []*backend.PodMetrics{ + { + Pod: extprocutils.FakePod(0), + Metrics: backend.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.2, + }, + }, + { + Pod: extprocutils.FakePod(1), + Metrics: backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + }, + }, + { + Pod: extprocutils.FakePod(2), + Metrics: backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + }, + }, + }, + wantHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: runserver.DefaultTargetEndpointKey, + RawValue: []byte("address-1"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte("76"), + }, + }, + }, + wantMetadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultTargetEndpointKey: { + Kind: &structpb.Value_StringValue{ + StringValue: "address-1", }, }, }, }, + wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"hello\",\"temperature\":0}"), + wantErr: false, + }, + { + name: "select active lora, low queue", + req: extprocutils.GenerateRequest("sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. pods: []*backend.PodMetrics{ @@ -93,8 +135,8 @@ func SKIPTestHandleRequestBody(t *testing.T) { WaitingQueueSize: 0, KVCacheUsagePercent: 0.1, ActiveModels: map[string]int{ - "foo": 1, - "my-model-v1": 1, + "foo": 1, + "sql-lora-1fdg2": 1, }, }, }, @@ -119,67 +161,67 @@ func SKIPTestHandleRequestBody(t *testing.T) { { Header: &configPb.HeaderValue{ Key: "Content-Length", - RawValue: []byte("73"), + RawValue: []byte("76"), }, }, }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-v1\",\"prompt\":\"hello\",\"temperature\":0}"), - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpServer(t, test.pods, test.models) - t.Cleanup(cleanup) - want := &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: test.wantHeaders, - }, - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_Body{ - Body: test.wantBody, - }, - }, + wantMetadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultTargetEndpointKey: { + Kind: &structpb.Value_StringValue{ + StringValue: "address-1", }, }, }, - } - res, err := sendRequest(t, client, test.req) - - if (err != nil) != test.wantErr { - t.Fatalf("Unexpected error, got %v, want %v", err, test.wantErr) - } - - if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { - t.Errorf("Unexpected response, (-want +got): %v", diff) - } - }) - } - -} - -func TestKubeInferenceModelRequest(t *testing.T) { - tests := []struct { - name string - req *extProcPb.ProcessingRequest - wantHeaders []*configPb.HeaderValueOption - wantMetadata *structpb.Struct - wantBody []byte - wantErr bool - }{ + }, + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"hello\",\"temperature\":0}"), + wantErr: false, + }, { - name: "success", + name: "select no lora despite active model, avoid excessive queue size", req: extprocutils.GenerateRequest("sql-lora"), - // pod-1 will be picked because it has relatively low queue size, with the requested - // model being active, and has low KV cache. + // pod-2 will be picked despite it NOT having the requested model being active + // as it's above the affinity for queue size. Also is critical, so we should + // still honor request despite all queues > 5 + pods: []*backend.PodMetrics{ + { + Pod: extprocutils.FakePod(0), + Metrics: backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + }, + { + Pod: extprocutils.FakePod(1), + Metrics: backend.Metrics{ + WaitingQueueSize: 50, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, + }, + }, + }, + { + Pod: extprocutils.FakePod(2), + Metrics: backend.Metrics{ + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + }, + }, + }, + }, wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("address-1"), + RawValue: []byte("address-2"), }, }, { @@ -193,7 +235,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { Fields: map[string]*structpb.Value{ runserver.DefaultTargetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: "address-1", + StringValue: "address-2", }, }, }, @@ -201,40 +243,122 @@ func TestKubeInferenceModelRequest(t *testing.T) { wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"hello\",\"temperature\":0}"), wantErr: false, }, - } - - pods := []*backend.PodMetrics{ { - Pod: extprocutils.FakePod(0), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, + name: "noncritical and all models past threshold, shed request", + req: extprocutils.GenerateRequest("sql-lora-sheddable"), + // no pods will be picked as all models are either above kv threshold, + // queue threshold, or both. + pods: []*backend.PodMetrics{ + { + Pod: extprocutils.FakePod(0), + Metrics: backend.Metrics{ + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + { + Pod: extprocutils.FakePod(1), + Metrics: backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + { + Pod: extprocutils.FakePod(2), + Metrics: backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, }, }, - }, - { - Pod: extprocutils.FakePod(1), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.1, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg2": 1, + wantHeaders: []*configPb.HeaderValueOption{}, + wantMetadata: &structpb.Struct{}, + wantBody: []byte(""), + wantErr: false, + immediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_TooManyRequests, }, }, }, { - Pod: extprocutils.FakePod(2), - Metrics: backend.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, + name: "noncritical, but one server has capacity, do not shed", + req: extprocutils.GenerateRequest("sql-lora-sheddable"), + // pod 0 will be picked as all other models are above threshold + pods: []*backend.PodMetrics{ + { + Pod: extprocutils.FakePod(0), + Metrics: backend.Metrics{ + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + { + Pod: extprocutils.FakePod(1), + Metrics: backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + { + Pod: extprocutils.FakePod(2), + Metrics: backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + }, + wantHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: runserver.DefaultTargetEndpointKey, + RawValue: []byte("address-0"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte("76"), + }, }, }, + wantMetadata: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultTargetEndpointKey: { + Kind: &structpb.Value_StringValue{ + StringValue: "address-0", + }, + }, + }, + }, + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"hello\",\"temperature\":0}"), + wantErr: false, }, } @@ -243,7 +367,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, pods) + client, cleanup := setUpHermeticServer(test.pods) t.Cleanup(cleanup) want := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestBody{ @@ -264,78 +388,24 @@ func TestKubeInferenceModelRequest(t *testing.T) { } res, err := sendRequest(t, client, test.req) - if err != nil { - if !test.wantErr { - t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) + if err != nil && !test.wantErr { + t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) + } + if test.immediateResponse != nil { + want = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: test.immediateResponse, + }, } - } else if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { + } + if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { t.Errorf("Unexpected response, (-want +got): %v", diff) } }) } } -func setUpServer(t *testing.T, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - t.Logf("Setting up ExtProc server") - server := extprocutils.StartExtProc(port, time.Second, time.Second, pods, models) - - address := fmt.Sprintf("localhost:%v", port) - // Create a grpc connection - conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - log.Fatalf("Failed to connect to %v: %v", address, err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) - if err != nil { - log.Fatalf("Failed to create client: %v", err) - } - return client, func() { - cancel() - conn.Close() - server.GracefulStop() - } -} - -func setUpHermeticServer(t *testing.T, pods []*backend.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - t.Logf("Setting up hermetic ExtProc server") - klog.InitFlags(nil) - flag.Parse() - // Configure klog verbosity levels to print ext proc logs. - _ = flag.Lookup("v").Value.Set("3") - - // Unmarshal CRDs from file into structs - manifestsPath := filepath.Join("..", "testdata", "inferencepool-with-model-hermetic.yaml") - docs, err := readDocuments(manifestsPath) - if err != nil { - log.Fatalf("Can't read object manifests at path %v, %v", manifestsPath, err) - } - - for _, doc := range docs { - inferenceModel := &v1alpha1.InferenceModel{} - if err = yaml.Unmarshal(doc, inferenceModel); err != nil { - log.Fatalf("Can't unmarshal object: %v", doc) - } - if inferenceModel.Kind == "InferenceModel" { - t.Logf("Creating inference model: %+v", inferenceModel) - if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { - log.Fatalf("unable to create inferenceModel %v: %v", inferenceModel.Name, err) - } - } - } - for _, doc := range docs { - inferencePool := &v1alpha1.InferencePool{} - if err = yaml.Unmarshal(doc, inferencePool); err != nil { - log.Fatalf("Can't unmarshal object: %v", doc) - } - if inferencePool.Kind == "InferencePool" { - t.Logf("Creating inference pool: %+v", inferencePool) - if err := k8sClient.Create(context.Background(), inferencePool); err != nil { - log.Fatalf("unable to create inferencePool %v: %v", inferencePool.Name, err) - } - } - } +func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { ps := make(backend.PodSet) pms := make(map[backend.Pod]*backend.PodMetrics) @@ -346,9 +416,6 @@ func setUpHermeticServer(t *testing.T, pods []*backend.PodMetrics) (client extPr pmc := &backend.FakePodMetricsClient{Res: pms} server := serverRunner.Start(backend.NewK8sDataStore(backend.WithPods(pods)), pmc) - if err != nil { - log.Fatalf("Ext-proc failed with the err: %v", err) - } // Wait the reconciler to populate the datastore. time.Sleep(10 * time.Second) @@ -408,6 +475,44 @@ func BeforeSuit() { go func() { serverRunner.StartManager() }() + + klog.Info("Setting up hermetic ExtProc server") + klog.InitFlags(nil) + flag.Parse() + // Configure klog verbosity levels to print ext proc logs. + _ = flag.Lookup("v").Value.Set("3") + + // Unmarshal CRDs from file into structs + manifestsPath := filepath.Join("..", "testdata", "inferencepool-with-model-hermetic.yaml") + docs, err := readDocuments(manifestsPath) + if err != nil { + log.Fatalf("Can't read object manifests at path %v, %v", manifestsPath, err) + } + + for _, doc := range docs { + inferenceModel := &v1alpha1.InferenceModel{} + if err = yaml.Unmarshal(doc, inferenceModel); err != nil { + log.Fatalf("Can't unmarshal object: %v", doc) + } + if inferenceModel.Kind == "InferenceModel" { + klog.Infof("Creating inference model: %+v", inferenceModel) + if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { + log.Fatalf("unable to create inferenceModel %v: %v", inferenceModel.Name, err) + } + } + } + for _, doc := range docs { + inferencePool := &v1alpha1.InferencePool{} + if err = yaml.Unmarshal(doc, inferencePool); err != nil { + log.Fatalf("Can't unmarshal object: %v", doc) + } + if inferencePool.Kind == "InferencePool" { + klog.Infof("Creating inference pool: %+v", inferencePool) + if err := k8sClient.Create(context.Background(), inferencePool); err != nil { + log.Fatalf("unable to create inferencePool %v: %v", inferencePool.Name, err) + } + } + } } func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { @@ -448,6 +553,3 @@ func readDocuments(fp string) ([][]byte, error) { } return docs, nil } -func pointer(v int32) *int32 { - return &v -} diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml index a07e0f35..372a8512 100644 --- a/test/testdata/inferencepool-with-model-hermetic.yaml +++ b/test/testdata/inferencepool-with-model-hermetic.yaml @@ -23,3 +23,30 @@ spec: targetModels: - name: sql-lora-1fdg2 weight: 100 +--- +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: InferenceModel +metadata: + name: inferencemodel-sheddable + namespace: default +spec: + modelName: sql-lora-sheddable + poolRef: + name: vllm-llama2-7b-pool + targetModels: + - name: sql-lora-1fdg3 + weight: 100 +--- +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: InferenceModel +metadata: + name: inferencemodel-generic + namespace: default +spec: + modelName: my-model + criticality: Critical + poolRef: + name: vllm-llama2-7b-pool + targetModels: + - name: my-model-12345 + weight: 100 From 71496247598d26526259419f4da006ca7aeb351e Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Mon, 10 Feb 2025 10:55:57 -0500 Subject: [PATCH 004/260] [Metrics] Add average kv cache and waiting queue size metrics for (#304) inference pool --- pkg/ext-proc/backend/provider.go | 38 +++++++++++- pkg/ext-proc/backend/provider_test.go | 2 +- pkg/ext-proc/main.go | 27 ++++---- pkg/ext-proc/metrics/README.md | 2 + pkg/ext-proc/metrics/metrics.go | 34 +++++++++++ pkg/ext-proc/metrics/metrics_test.go | 52 ++++++++++++++++ .../metrics/testdata/kv_cache_avg_metrics | 3 + .../metrics/testdata/queue_avg_size_metrics | 3 + pkg/ext-proc/server/runserver.go | 61 ++++++++++--------- pkg/ext-proc/test/benchmark/benchmark.go | 13 ++-- pkg/ext-proc/test/utils.go | 4 +- 11 files changed, 189 insertions(+), 50 deletions(-) create mode 100644 pkg/ext-proc/metrics/testdata/kv_cache_avg_metrics create mode 100644 pkg/ext-proc/metrics/testdata/queue_avg_size_metrics diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index 8bf67257..a9165e8f 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -7,6 +7,7 @@ import ( "time" "go.uber.org/multierr" + "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" ) @@ -58,7 +59,7 @@ func (p *Provider) GetPodMetrics(pod Pod) (*PodMetrics, bool) { return nil, false } -func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval time.Duration) error { +func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { p.refreshPodsOnce() if err := p.refreshMetricsOnce(); err != nil { @@ -85,6 +86,14 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval time.Duratio } }() + // Periodically flush prometheus metrics for inference pool + go func() { + for { + time.Sleep(refreshPrometheusMetricsInterval) + p.flushPrometheusMetricsOnce() + } + }() + // Periodically print out the pods and metrics for DEBUGGING. if klog.V(logutil.DEBUG).Enabled() { go func() { @@ -174,3 +183,30 @@ func (p *Provider) refreshMetricsOnce() error { } return errs } + +func (p *Provider) flushPrometheusMetricsOnce() { + klog.V(logutil.DEBUG).Infof("Flushing Prometheus Metrics") + + pool, _ := p.datastore.getInferencePool() + if pool == nil { + // No inference pool or not initialize. + return + } + + var kvCacheTotal float64 + var queueTotal int + + podMetrics := p.AllPodMetrics() + if len(podMetrics) == 0 { + return + } + + for _, pod := range podMetrics { + kvCacheTotal += pod.KVCacheUsagePercent + queueTotal += pod.WaitingQueueSize + } + + podTotalCount := len(podMetrics) + metrics.RecordInferencePoolAvgKVCache(pool.Name, kvCacheTotal/float64(podTotalCount)) + metrics.RecordInferencePoolAvgQueueSize(pool.Name, float64(queueTotal/podTotalCount)) +} diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/ext-proc/backend/provider_test.go index ad231f57..ddd7f0d6 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/ext-proc/backend/provider_test.go @@ -90,7 +90,7 @@ func TestProvider(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { p := NewProvider(test.pmc, test.datastore) - err := p.Init(time.Millisecond, time.Millisecond) + err := p.Init(time.Millisecond, time.Millisecond, time.Millisecond) if test.initErr != (err != nil) { t.Fatalf("Unexpected error, got: %v, want: %v", err, test.initErr) } diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index a783aa2c..e126b6dd 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -69,6 +69,10 @@ var ( "refreshMetricsInterval", runserver.DefaultRefreshMetricsInterval, "interval to refresh metrics") + refreshPrometheusMetricsInterval = flag.Duration( + "refreshPrometheusMetricsInterval", + runserver.DefaultRefreshPrometheusMetricsInterval, + "interval to flush prometheus metrics") scheme = runtime.NewScheme() ) @@ -102,17 +106,18 @@ func main() { datastore := backend.NewK8sDataStore() serverRunner := &runserver.ExtProcServerRunner{ - GrpcPort: *grpcPort, - TargetEndpointKey: *targetEndpointKey, - PoolName: *poolName, - PoolNamespace: *poolNamespace, - ServiceName: *serviceName, - Zone: *zone, - RefreshPodsInterval: *refreshPodsInterval, - RefreshMetricsInterval: *refreshMetricsInterval, - Scheme: scheme, - Config: ctrl.GetConfigOrDie(), - Datastore: datastore, + GrpcPort: *grpcPort, + TargetEndpointKey: *targetEndpointKey, + PoolName: *poolName, + PoolNamespace: *poolNamespace, + ServiceName: *serviceName, + Zone: *zone, + RefreshPodsInterval: *refreshPodsInterval, + RefreshMetricsInterval: *refreshMetricsInterval, + RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, + Scheme: scheme, + Config: ctrl.GetConfigOrDie(), + Datastore: datastore, } serverRunner.Setup() diff --git a/pkg/ext-proc/metrics/README.md b/pkg/ext-proc/metrics/README.md index 1094bc23..8adfd94e 100644 --- a/pkg/ext-proc/metrics/README.md +++ b/pkg/ext-proc/metrics/README.md @@ -46,6 +46,8 @@ spec: | inference_model_response_sizes | Distribution | Distribution of response size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_input_tokens | Distribution | Distribution of input token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_output_tokens | Distribution | Distribution of output token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | +| inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | ## Scrape Metrics diff --git a/pkg/ext-proc/metrics/metrics.go b/pkg/ext-proc/metrics/metrics.go index 8cb7bd27..7bdc8436 100644 --- a/pkg/ext-proc/metrics/metrics.go +++ b/pkg/ext-proc/metrics/metrics.go @@ -11,9 +11,11 @@ import ( const ( InferenceModelComponent = "inference_model" + InferencePoolComponent = "inference_pool" ) var ( + // Inference Model Metrics requestCounter = compbasemetrics.NewCounterVec( &compbasemetrics.CounterOpts{ Subsystem: InferenceModelComponent, @@ -88,6 +90,27 @@ var ( }, []string{"model_name", "target_model_name"}, ) + + // Inference Pool Metrics + inferencePoolAvgKVCache = compbasemetrics.NewGaugeVec( + &compbasemetrics.GaugeOpts{ + Subsystem: InferencePoolComponent, + Name: "average_kv_cache_utilization", + Help: "The average kv cache utilization for an inference server pool.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"name"}, + ) + + inferencePoolAvgQueueSize = compbasemetrics.NewGaugeVec( + &compbasemetrics.GaugeOpts{ + Subsystem: InferencePoolComponent, + Name: "average_queue_size", + Help: "The average number of requests pending in the model server queue.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"name"}, + ) ) var registerMetrics sync.Once @@ -101,6 +124,9 @@ func Register() { legacyregistry.MustRegister(responseSizes) legacyregistry.MustRegister(inputTokens) legacyregistry.MustRegister(outputTokens) + + legacyregistry.MustRegister(inferencePoolAvgKVCache) + legacyregistry.MustRegister(inferencePoolAvgQueueSize) }) } @@ -143,3 +169,11 @@ func RecordOutputTokens(modelName, targetModelName string, size int) { outputTokens.WithLabelValues(modelName, targetModelName).Observe(float64(size)) } } + +func RecordInferencePoolAvgKVCache(name string, utilization float64) { + inferencePoolAvgKVCache.WithLabelValues(name).Set(utilization) +} + +func RecordInferencePoolAvgQueueSize(name string, queueSize float64) { + inferencePoolAvgQueueSize.WithLabelValues(name).Set(queueSize) +} diff --git a/pkg/ext-proc/metrics/metrics_test.go b/pkg/ext-proc/metrics/metrics_test.go index 57774b11..348f707e 100644 --- a/pkg/ext-proc/metrics/metrics_test.go +++ b/pkg/ext-proc/metrics/metrics_test.go @@ -15,6 +15,8 @@ const RequestSizesMetric = InferenceModelComponent + "_request_sizes" const ResponseSizesMetric = InferenceModelComponent + "_response_sizes" const InputTokensMetric = InferenceModelComponent + "_input_tokens" const OutputTokensMetric = InferenceModelComponent + "_output_tokens" +const KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" +const QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" func TestRecordRequestCounterandSizes(t *testing.T) { type requests struct { @@ -257,3 +259,53 @@ func TestRecordResponseMetrics(t *testing.T) { }) } } + +func TestInferencePoolMetrics(t *testing.T) { + scenarios := []struct { + name string + poolName string + kvCacheAvg float64 + queueSizeAvg float64 + }{ + { + name: "basic test", + poolName: "p1", + kvCacheAvg: 0.3, + queueSizeAvg: 0.4, + }, + } + Register() + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + + RecordInferencePoolAvgKVCache(scenario.poolName, scenario.kvCacheAvg) + RecordInferencePoolAvgQueueSize(scenario.poolName, scenario.queueSizeAvg) + + wantKVCache, err := os.Open("testdata/kv_cache_avg_metrics") + defer func() { + if err := wantKVCache.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantKVCache, KVCacheAvgUsageMetric); err != nil { + t.Error(err) + } + + wantQueueSize, err := os.Open("testdata/queue_avg_size_metrics") + defer func() { + if err := wantQueueSize.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantQueueSize, QueueAvgSizeMetric); err != nil { + t.Error(err) + } + }) + } +} diff --git a/pkg/ext-proc/metrics/testdata/kv_cache_avg_metrics b/pkg/ext-proc/metrics/testdata/kv_cache_avg_metrics new file mode 100644 index 00000000..99d1a93a --- /dev/null +++ b/pkg/ext-proc/metrics/testdata/kv_cache_avg_metrics @@ -0,0 +1,3 @@ +# HELP inference_pool_average_kv_cache_utilization [ALPHA] The average kv cache utilization for an inference server pool. +# TYPE inference_pool_average_kv_cache_utilization gauge +inference_pool_average_kv_cache_utilization{name="p1"} 0.3 diff --git a/pkg/ext-proc/metrics/testdata/queue_avg_size_metrics b/pkg/ext-proc/metrics/testdata/queue_avg_size_metrics new file mode 100644 index 00000000..3605740c --- /dev/null +++ b/pkg/ext-proc/metrics/testdata/queue_avg_size_metrics @@ -0,0 +1,3 @@ +# HELP inference_pool_average_queue_size [ALPHA] The average number of requests pending in the model server queue. +# TYPE inference_pool_average_queue_size gauge +inference_pool_average_queue_size{name="p1"} 0.4 diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index 1c9c1b2e..bf666f1f 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -19,42 +19,45 @@ import ( // ExtProcServerRunner provides methods to manage an external process server. type ExtProcServerRunner struct { - GrpcPort int - TargetEndpointKey string - PoolName string - PoolNamespace string - ServiceName string - Zone string - RefreshPodsInterval time.Duration - RefreshMetricsInterval time.Duration - Scheme *runtime.Scheme - Config *rest.Config - Datastore *backend.K8sDatastore - manager ctrl.Manager + GrpcPort int + TargetEndpointKey string + PoolName string + PoolNamespace string + ServiceName string + Zone string + RefreshPodsInterval time.Duration + RefreshMetricsInterval time.Duration + RefreshPrometheusMetricsInterval time.Duration + Scheme *runtime.Scheme + Config *rest.Config + Datastore *backend.K8sDatastore + manager ctrl.Manager } // Default values for CLI flags in main const ( - DefaultGrpcPort = 9002 // default for --grpcPort - DefaultTargetEndpointKey = "x-gateway-destination-endpoint" // default for --targetEndpointKey - DefaultPoolName = "" // required but no default - DefaultPoolNamespace = "default" // default for --poolNamespace - DefaultServiceName = "" // required but no default - DefaultZone = "" // default for --zone - DefaultRefreshPodsInterval = 10 * time.Second // default for --refreshPodsInterval - DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval + DefaultGrpcPort = 9002 // default for --grpcPort + DefaultTargetEndpointKey = "x-gateway-destination-endpoint" // default for --targetEndpointKey + DefaultPoolName = "" // required but no default + DefaultPoolNamespace = "default" // default for --poolNamespace + DefaultServiceName = "" // required but no default + DefaultZone = "" // default for --zone + DefaultRefreshPodsInterval = 10 * time.Second // default for --refreshPodsInterval + DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval + DefaultRefreshPrometheusMetricsInterval = 5 * time.Second // default for --refreshPrometheusMetricsInterval ) func NewDefaultExtProcServerRunner() *ExtProcServerRunner { return &ExtProcServerRunner{ - GrpcPort: DefaultGrpcPort, - TargetEndpointKey: DefaultTargetEndpointKey, - PoolName: DefaultPoolName, - PoolNamespace: DefaultPoolNamespace, - ServiceName: DefaultServiceName, - Zone: DefaultZone, - RefreshPodsInterval: DefaultRefreshPodsInterval, - RefreshMetricsInterval: DefaultRefreshMetricsInterval, + GrpcPort: DefaultGrpcPort, + TargetEndpointKey: DefaultTargetEndpointKey, + PoolName: DefaultPoolName, + PoolNamespace: DefaultPoolNamespace, + ServiceName: DefaultServiceName, + Zone: DefaultZone, + RefreshPodsInterval: DefaultRefreshPodsInterval, + RefreshMetricsInterval: DefaultRefreshMetricsInterval, + RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, // Scheme, Config, and Datastore can be assigned later. } } @@ -123,7 +126,7 @@ func (r *ExtProcServerRunner) Start( // Initialize backend provider pp := backend.NewProvider(podMetricsClient, podDatastore) - if err := pp.Init(r.RefreshPodsInterval, r.RefreshMetricsInterval); err != nil { + if err := pp.Init(r.RefreshPodsInterval, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { klog.Fatalf("Failed to initialize backend provider: %v", err) } diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index 9ff61d8b..abaeedbb 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -21,11 +21,12 @@ var ( svrAddr = flag.String("server_address", fmt.Sprintf("localhost:%d", runserver.DefaultGrpcPort), "Address of the ext proc server") totalRequests = flag.Int("total_requests", 100000, "number of requests to be sent for load test") // Flags when running a local ext proc server. - numFakePods = flag.Int("num_fake_pods", 200, "number of fake pods when running a local ext proc server") - numModelsPerPod = flag.Int("num_models_per_pod", 5, "number of fake models per pod when running a local ext proc server") - localServer = flag.Bool("local_server", true, "whether to start a local ext proc server") - refreshPodsInterval = flag.Duration("refreshPodsInterval", 10*time.Second, "interval to refresh pods") - refreshMetricsInterval = flag.Duration("refreshMetricsInterval", 50*time.Millisecond, "interval to refresh metrics") + numFakePods = flag.Int("num_fake_pods", 200, "number of fake pods when running a local ext proc server") + numModelsPerPod = flag.Int("num_models_per_pod", 5, "number of fake models per pod when running a local ext proc server") + localServer = flag.Bool("local_server", true, "whether to start a local ext proc server") + refreshPodsInterval = flag.Duration("refreshPodsInterval", 10*time.Second, "interval to refresh pods") + refreshMetricsInterval = flag.Duration("refreshMetricsInterval", 50*time.Millisecond, "interval to refresh metrics via polling pods") + refreshPrometheusMetricsInterval = flag.Duration("refreshPrometheusMetricsInterval", 5*time.Second, "interval to flush prometheus metrics") ) const ( @@ -37,7 +38,7 @@ func main() { flag.Parse() if *localServer { - test.StartExtProc(port, *refreshPodsInterval, *refreshMetricsInterval, fakePods(), fakeModels()) + test.StartExtProc(port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) time.Sleep(time.Second) // wait until server is up klog.Info("Server started") } diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index 63972849..98793b95 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -16,7 +16,7 @@ import ( klog "k8s.io/klog/v2" ) -func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) *grpc.Server { +func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) *grpc.Server { ps := make(backend.PodSet) pms := make(map[backend.Pod]*backend.PodMetrics) for _, pod := range pods { @@ -25,7 +25,7 @@ func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval time.Dur } pmc := &backend.FakePodMetricsClient{Res: pms} pp := backend.NewProvider(pmc, backend.NewK8sDataStore(backend.WithPods(pods))) - if err := pp.Init(refreshPodsInterval, refreshMetricsInterval); err != nil { + if err := pp.Init(refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { klog.Fatalf("failed to initialize: %v", err) } return startExtProc(port, pp, models) From 836ef57d27cb4424d5e87f12ea2bde0aec4646a1 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:49:57 -0700 Subject: [PATCH 005/260] Move getting started guide to docs site (#308) * Link to v0.1.0 getting started guide * Moving getting started guide to the site * site doesnt support markdown syntax for ordered lists, making explicit * fiddling with mkdocs syntax --- pkg/README.md | 95 +--------------------------------------- site-src/guides/index.md | 87 +++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 95 deletions(-) diff --git a/pkg/README.md b/pkg/README.md index 04ebfde2..b53ef777 100644 --- a/pkg/README.md +++ b/pkg/README.md @@ -1,96 +1,3 @@ ## Quickstart -This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get a first, single InferencePool up and running! - -### Requirements - - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - - A cluster with: - - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, - you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - - 3 GPUs to run the sample model server. Adjust the number of replicas in `./manifests/vllm/deployment.yaml` as needed. - -### Steps - -1. **Deploy Sample Model Server** - - Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. - Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. - ```bash - kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/vllm/deployment.yaml - ``` - -1. **Install the Inference Extension CRDs:** - - ```sh - kubectl apply -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd - ``` - -1. **Deploy InferenceModel** - - Deploy the sample InferenceModel which is configured to load balance traffic between the `tweet-summary-0` and `tweet-summary-1` - [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/inferencemodel.yaml - ``` - -1. **Update Envoy Gateway Config to enable Patch Policy** - - Our custom LLM Gateway ext-proc is patched into the existing envoy gateway via `EnvoyPatchPolicy`. To enable this feature, we must extend the Envoy Gateway config map. To do this, simply run: - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/enable_patch_policy.yaml - kubectl rollout restart deployment envoy-gateway -n envoy-gateway-system - ``` - Additionally, if you would like to enable the admin interface, you can uncomment the admin lines and run this again. - -1. **Deploy Gateway** - - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/gateway.yaml - ``` - > **_NOTE:_** This file couples together the gateway infra and the HTTPRoute infra for a convenient, quick startup. Creating additional/different InferencePools on the same gateway will require an additional set of: `Backend`, `HTTPRoute`, the resources included in the `./manifests/gateway/ext-proc.yaml` file, and an additional `./manifests/gateway/patch_policy.yaml` file. ***Should you choose to experiment, familiarity with xDS and Envoy are very useful.*** - - Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: - ```bash - $ kubectl get gateway inference-gateway - NAME CLASS ADDRESS PROGRAMMED AGE - inference-gateway inference-gateway True 22s - ``` - -1. **Deploy the Inference Extension and InferencePool** - - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/ext_proc.yaml - ``` - -1. **Deploy Envoy Gateway Custom Policies** - - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/extension_policy.yaml - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/patch_policy.yaml - ``` - > **_NOTE:_** This is also per InferencePool, and will need to be configured to support the new pool should you wish to experiment further. - -1. **OPTIONALLY**: Apply Traffic Policy - - For high-traffic benchmarking you can apply this manifest to avoid any defaults that can cause timeouts/errors. - - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/traffic_policy.yaml - ``` - -1. **Try it out** - - Wait until the gateway is ready. - - ```bash - IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') - PORT=8081 - - curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ - "model": "tweet-summary", - "prompt": "Write as if you were a critic: San Francisco", - "max_tokens": 100, - "temperature": 0 - }' - ``` \ No newline at end of file +Please refer to our Getting started guide here: https://gateway-api-inference-extension.sigs.k8s.io/guides/ \ No newline at end of file diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 92f6412a..e4cbec6f 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -1,3 +1,88 @@ # Getting started with Gateway API Inference Extension -TODO \ No newline at end of file +This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get a first, single InferencePool up and running! + +### Requirements + - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher + - A cluster with: + - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, + you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). + - 3 GPUs to run the sample model server. Adjust the number of replicas in `./manifests/vllm/deployment.yaml` as needed. + +### Steps + +1. **Deploy Sample Model Server** + + Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. + Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. + ```bash + kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/vllm/deployment.yaml + ``` +1. **Install the Inference Extension CRDs:** + + ```sh + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v0.1.0/manifests.yaml + +1. **Deploy InferenceModel** + + Deploy the sample InferenceModel which is configured to load balance traffic between the `tweet-summary-0` and `tweet-summary-1` + [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/inferencemodel.yaml + ``` +1. **Update Envoy Gateway Config to enable Patch Policy** + + Our custom LLM Gateway ext-proc is patched into the existing envoy gateway via `EnvoyPatchPolicy`. To enable this feature, we must extend the Envoy Gateway config map. To do this, simply run: + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/enable_patch_policy.yaml + kubectl rollout restart deployment envoy-gateway -n envoy-gateway-system + ``` + Additionally, if you would like to enable the admin interface, you can uncomment the admin lines and run this again. +1. **Deploy Gateway** + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/gateway.yaml + ``` + > **_NOTE:_** This file couples together the gateway infra and the HTTPRoute infra for a convenient, quick startup. Creating additional/different InferencePools on the same gateway will require an additional set of: `Backend`, `HTTPRoute`, the resources included in the `./manifests/gateway/ext-proc.yaml` file, and an additional `./manifests/gateway/patch_policy.yaml` file. ***Should you choose to experiment, familiarity with xDS and Envoy are very useful.*** + + Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: + ```bash + $ kubectl get gateway inference-gateway + NAME CLASS ADDRESS PROGRAMMED AGE + inference-gateway inference-gateway True 22s + ``` +1. **Deploy the Inference Extension and InferencePool** + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/ext_proc.yaml + ``` +1. **Deploy Envoy Gateway Custom Policies** + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/extension_policy.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/patch_policy.yaml + ``` + > **_NOTE:_** This is also per InferencePool, and will need to be configured to support the new pool should you wish to experiment further. +1. **OPTIONALLY**: Apply Traffic Policy + + For high-traffic benchmarking you can apply this manifest to avoid any defaults that can cause timeouts/errors. + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/traffic_policy.yaml + ``` +1. **Try it out** + + Wait until the gateway is ready. + + ```bash + IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') + PORT=8081 + + curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ + "model": "tweet-summary", + "prompt": "Write as if you were a critic: San Francisco", + "max_tokens": 100, + "temperature": 0 + }' + ``` \ No newline at end of file From 6dd58f2546a1a4109bb836611db6634928f74d01 Mon Sep 17 00:00:00 2001 From: Tim Flannagan Date: Mon, 10 Feb 2025 15:05:57 -0500 Subject: [PATCH 006/260] site-source: Fix 'Bakcground' misspell in API concepts page (#309) Signed-off-by: timflannagan --- site-src/concepts/api-overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site-src/concepts/api-overview.md b/site-src/concepts/api-overview.md index 94e76251..6c4c9ecd 100644 --- a/site-src/concepts/api-overview.md +++ b/site-src/concepts/api-overview.md @@ -1,7 +1,7 @@ # API Overview -## Bakcground -The Gateway API Inference Extension project is an extension of the Kubernetes Gateway API for serving Generative AI models on Kubernetes. Gateway API Inference Extension facilitates standardization of APIs for Kubernetes cluster operators and developers running generative AI inference, while allowing flexibility for underlying gateway implementations (such as Envoy Proxy) to iterate on mechanisms for optimized serving of models. +## Background +The Gateway API Inference Extension project is an extension of the Kubernetes Gateway API for serving Generative AI models on Kubernetes. Gateway API Inference Extension facilitates standardization of APIs for Kubernetes cluster operators and developers running generative AI inference, while allowing flexibility for underlying gateway implementations (such as Envoy Proxy) to iterate on mechanisms for optimized serving of models. Overview of API integration From d74eefa69e573481ff88ab7732f3ac40a1121e38 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:31:58 -0700 Subject: [PATCH 007/260] Mkdocs fixes (#314) * Link to v0.1.0 getting started guide * Moving getting started guide to the site * site doesnt support markdown syntax for ordered lists, making explicit * fiddling with mkdocs syntax * mkdocs fixes --- site-src/concepts/api-overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site-src/concepts/api-overview.md b/site-src/concepts/api-overview.md index 6c4c9ecd..9c5c0416 100644 --- a/site-src/concepts/api-overview.md +++ b/site-src/concepts/api-overview.md @@ -9,8 +9,8 @@ The Gateway API Inference Extension project is an extension of the Kubernetes Ga ### InferencePool -InferencePool represents a set of Inference-focused Pods and an extension that will be used to route to them. Within the broader Gateway API resource model, this resource is considered a "backend". In practice, that means that you'd replace a Kubernetes Service with an InferencePool. This resource has some similarities to Service (a way to select Pods and specify a port), but has some unique capabilities. With InferenceModel, you can configure a routing extension as well as inference-specific routing optimizations. For more information on this resource, refer to our [InferencePool documentation](/api-types/inferencepool.md) or go directly to the [InferencePool spec](/reference/spec/#inferencepool). +InferencePool represents a set of Inference-focused Pods and an extension that will be used to route to them. Within the broader Gateway API resource model, this resource is considered a "backend". In practice, that means that you'd replace a Kubernetes Service with an InferencePool. This resource has some similarities to Service (a way to select Pods and specify a port), but has some unique capabilities. With InferenceModel, you can configure a routing extension as well as inference-specific routing optimizations. For more information on this resource, refer to our [InferencePool documentation](/api-types/inferencepool) or go directly to the [InferencePool spec](/reference/spec/#inferencepool). ### InferenceModel -An InferenceModel represents a model or adapter, and configuration associated with that model. This resource enables you to configure the relative criticality of a model, and allows you to seamlessly translate the requested model name to one or more backend model names. Multiple InferenceModels can be attached to an InferencePool. For more information on this resource, refer to our [InferenceModel documentation](/api-types/inferencemodel.md) or go directly to the [InferenceModel spec](/reference/spec/#inferencemodel). +An InferenceModel represents a model or adapter, and configuration associated with that model. This resource enables you to configure the relative criticality of a model, and allows you to seamlessly translate the requested model name to one or more backend model names. Multiple InferenceModels can be attached to an InferencePool. For more information on this resource, refer to our [InferenceModel documentation](/api-types/inferencemodel) or go directly to the [InferenceModel spec](/reference/spec/#inferencemodel). From 5ad2888423ee795b3fbed1ec218eb2656e3e9bb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 14:39:57 -0800 Subject: [PATCH 008/260] Bump google.golang.org/protobuf from 1.36.4 to 1.36.5 (#315) Bumps google.golang.org/protobuf from 1.36.4 to 1.36.5. --- updated-dependencies: - dependency-name: google.golang.org/protobuf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8dd59e3e..d774d6bd 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 google.golang.org/grpc v1.70.0 - google.golang.org/protobuf v1.36.4 + google.golang.org/protobuf v1.36.5 k8s.io/api v0.32.1 k8s.io/apiextensions-apiserver v0.32.1 k8s.io/apimachinery v0.32.1 diff --git a/go.sum b/go.sum index 6d1cd8bd..803ed988 100644 --- a/go.sum +++ b/go.sum @@ -329,8 +329,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From b5ffb664e20289b728a660ee50f3f63f9b35a827 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Mon, 10 Feb 2025 23:37:56 +0000 Subject: [PATCH 009/260] Remove gci linter (#317) --- .golangci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index 1462bcc7..2ad3b93d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,7 +14,6 @@ linters: - dupword - durationcheck - fatcontext - - gci - ginkgolinter - gocritic - govet From 6c22d92eb4594e1d560740de892432b87778e2f3 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Mon, 10 Feb 2025 18:47:56 -0500 Subject: [PATCH 010/260] Adds ErrorNotFound Handling for Reconciler (#286) Signed-off-by: Daneyon Hansen --- .../backend/inferencemodel_reconciler.go | 34 +++-- .../backend/inferencemodel_reconciler_test.go | 133 ++++++++++++++++-- 2 files changed, 138 insertions(+), 29 deletions(-) diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/backend/inferencemodel_reconciler.go index 3164e098..1c1d2278 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler.go @@ -5,6 +5,7 @@ import ( "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" @@ -25,32 +26,37 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque if req.Namespace != c.PoolNamespacedName.Namespace { return ctrl.Result{}, nil } - klog.V(1).Infof("reconciling InferenceModel %v", req.NamespacedName) - - service := &v1alpha1.InferenceModel{} - if err := c.Get(ctx, req.NamespacedName, service); err != nil { - klog.Error(err, "unable to get InferencePool") + klog.V(1).Infof("Reconciling InferenceModel %v", req.NamespacedName) + + infModel := &v1alpha1.InferenceModel{} + if err := c.Get(ctx, req.NamespacedName, infModel); err != nil { + if errors.IsNotFound(err) { + klog.V(1).Infof("InferenceModel %v not found. Removing from datastore since object must be deleted", req.NamespacedName) + c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) + return ctrl.Result{}, nil + } + klog.Error(err, "Unable to get InferenceModel") return ctrl.Result{}, err } - c.updateDatastore(service) + c.updateDatastore(infModel) return ctrl.Result{}, nil } -func (c *InferenceModelReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.InferenceModel{}). - Complete(c) -} - func (c *InferenceModelReconciler) updateDatastore(infModel *v1alpha1.InferenceModel) { if infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name { klog.V(1).Infof("Incoming pool ref %v, server pool name: %v", infModel.Spec.PoolRef, c.PoolNamespacedName.Name) - klog.V(1).Infof("Adding/Updating inference model: %v", infModel.Spec.ModelName) + klog.V(1).Infof("Adding/Updating InferenceModel: %v", infModel.Spec.ModelName) c.Datastore.InferenceModels.Store(infModel.Spec.ModelName, infModel) return } - klog.V(logutil.DEFAULT).Infof("Removing/Not adding inference model: %v", infModel.Spec.ModelName) + klog.V(logutil.DEFAULT).Infof("Removing/Not adding InferenceModel: %v", infModel.Spec.ModelName) // If we get here. The model is not relevant to this pool, remove. c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) } + +func (c *InferenceModelReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.InferenceModel{}). + Complete(c) +} diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index 5609ca53..45669a30 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -1,16 +1,22 @@ package backend import ( + "context" "sync" "testing" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) var ( - service1 = &v1alpha1.InferenceModel{ + infModel1 = &v1alpha1.InferenceModel{ Spec: v1alpha1.InferenceModelSpec{ ModelName: "fake model1", PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, @@ -19,7 +25,7 @@ var ( Name: "test-service", }, } - service1Modified = &v1alpha1.InferenceModel{ + infModel1Modified = &v1alpha1.InferenceModel{ Spec: v1alpha1.InferenceModelSpec{ ModelName: "fake model1", PoolRef: v1alpha1.PoolObjectReference{Name: "test-poolio"}, @@ -28,7 +34,7 @@ var ( Name: "test-service", }, } - service2 = &v1alpha1.InferenceModel{ + infModel2 = &v1alpha1.InferenceModel{ Spec: v1alpha1.InferenceModelSpec{ ModelName: "fake model", PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, @@ -60,8 +66,8 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { }, InferenceModels: &sync.Map{}, }, - incomingService: service1, - wantInferenceModels: populateServiceMap(service1), + incomingService: infModel1, + wantInferenceModels: populateServiceMap(infModel1), }, { name: "Removing existing service.", @@ -75,9 +81,9 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: populateServiceMap(service1), + InferenceModels: populateServiceMap(infModel1), }, - incomingService: service1Modified, + incomingService: infModel1Modified, wantInferenceModels: populateServiceMap(), }, { @@ -92,7 +98,7 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: populateServiceMap(service1), + InferenceModels: populateServiceMap(infModel1), }, incomingService: &v1alpha1.InferenceModel{ Spec: v1alpha1.InferenceModelSpec{ @@ -103,7 +109,7 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { Name: "unrelated-service", }, }, - wantInferenceModels: populateServiceMap(service1), + wantInferenceModels: populateServiceMap(infModel1), }, { name: "Add to existing", @@ -117,27 +123,124 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: populateServiceMap(service1), + InferenceModels: populateServiceMap(infModel1), }, - incomingService: service2, - wantInferenceModels: populateServiceMap(service1, service2), + incomingService: infModel2, + wantInferenceModels: populateServiceMap(infModel1, infModel2), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - InferenceModelReconciler := &InferenceModelReconciler{ + reconciler := &InferenceModelReconciler{ Datastore: test.datastore, PoolNamespacedName: types.NamespacedName{Name: test.datastore.inferencePool.Name}, } - InferenceModelReconciler.updateDatastore(test.incomingService) + reconciler.updateDatastore(test.incomingService) - if ok := mapsEqual(InferenceModelReconciler.Datastore.InferenceModels, test.wantInferenceModels); !ok { + if ok := mapsEqual(reconciler.Datastore.InferenceModels, test.wantInferenceModels); !ok { t.Error("Maps are not equal") } }) } } +func TestReconcile_ResourceNotFound(t *testing.T) { + // Set up the scheme. + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + + // Create a fake client with no InferenceModel objects. + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + // Create a minimal datastore. + datastore := &K8sDatastore{ + InferenceModels: &sync.Map{}, + inferencePool: &v1alpha1.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, + }, + } + + // Create the reconciler. + reconciler := &InferenceModelReconciler{ + Client: fakeClient, + Scheme: scheme, + Record: record.NewFakeRecorder(10), + Datastore: datastore, + PoolNamespacedName: types.NamespacedName{Name: "test-pool"}, + } + + // Create a request for a non-existent resource. + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "non-existent-model", Namespace: "default"}} + + // Call Reconcile. + result, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("expected no error when resource is not found, got %v", err) + } + + // Check that no requeue is requested. + if result.Requeue || result.RequeueAfter != 0 { + t.Errorf("expected no requeue, got %+v", result) + } +} + +func TestReconcile_ResourceExists(t *testing.T) { + // Set up the scheme. + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + + // Create an InferenceModel object. + existingModel := &v1alpha1.InferenceModel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-model", + Namespace: "default", + }, + Spec: v1alpha1.InferenceModelSpec{ + ModelName: "fake-model", + PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, + }, + } + + // Create a fake client with the existing model. + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() + + // Create a minimal datastore. + datastore := &K8sDatastore{ + InferenceModels: &sync.Map{}, + inferencePool: &v1alpha1.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, + }, + } + + // Create the reconciler. + reconciler := &InferenceModelReconciler{ + Client: fakeClient, + Scheme: scheme, + Record: record.NewFakeRecorder(10), + Datastore: datastore, + PoolNamespacedName: types.NamespacedName{Name: "test-pool", Namespace: "default"}, + } + + // Create a request for the existing resource. + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "existing-model", Namespace: "default"}} + + // Call Reconcile. + result, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("expected no error when resource exists, got %v", err) + } + + // Check that no requeue is requested. + if result.Requeue || result.RequeueAfter != 0 { + t.Errorf("expected no requeue, got %+v", result) + } + + // Verify that the datastore was updated. + if _, ok := datastore.InferenceModels.Load(existingModel.Spec.ModelName); !ok { + t.Errorf("expected datastore to contain model %q", existingModel.Spec.ModelName) + } +} + func populateServiceMap(services ...*v1alpha1.InferenceModel) *sync.Map { returnVal := &sync.Map{} From d808d559535356375d07128351a55447a38fd4d8 Mon Sep 17 00:00:00 2001 From: Tim Flannagan Date: Tue, 11 Feb 2025 14:39:58 -0500 Subject: [PATCH 011/260] site-src: Replace k8sgateway with kgateway & fix spelling in roles-and-personas.md (#311) * site-src: Fix spelling/grammar issues in roles-and-personas.md Signed-off-by: timflannagan * site-src: Replace k8sgateway with kgateway in implementations.md Signed-off-by: timflannagan * site-src: Use the correct GW API naming Signed-off-by: timflannagan --------- Signed-off-by: timflannagan --- site-src/concepts/roles-and-personas.md | 6 +++--- site-src/implementations.md | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/site-src/concepts/roles-and-personas.md b/site-src/concepts/roles-and-personas.md index b11f43eb..0746adbf 100644 --- a/site-src/concepts/roles-and-personas.md +++ b/site-src/concepts/roles-and-personas.md @@ -1,10 +1,10 @@ # Roles and Personas -Before diving into the details of the API, decriptions of the personas these APIs were designed for will help convey the thought process of the API design. +Before diving into the details of the API, descriptions of the personas these APIs were designed for will help convey the thought process of the API design. ## Inference Platform Admin -The Inference Platform Admin creates and manages the infrastructure necessary to run LLM workloads. Including handling Ops for: +The Inference Platform Admin creates and manages the infrastructure necessary to run LLM workloads, including handling Ops for: - Hardware - Model Server @@ -15,7 +15,7 @@ The Inference Platform Admin creates and manages the infrastructure necessary to ## Inference Workload Owner -An Inference Workload Owner persona owns and manages 1 or many Generative AI Workloads (LLM focused *currently*). This includes: +An Inference Workload Owner persona owns and manages one or many Generative AI Workloads (LLM focused *currently*). This includes: - Defining criticality - Managing fine-tunes diff --git a/site-src/implementations.md b/site-src/implementations.md index e2238827..89acb436 100644 --- a/site-src/implementations.md +++ b/site-src/implementations.md @@ -3,14 +3,15 @@ This project has several implementations that are planned or in progress: * [Envoy Gateway][1] -* [Gloo k8sgateway][2] +* [Kgateway][2] * [Google Kubernetes Engine][3] [1]:#envoy-gateway -[2]:#gloo-k8sgateway +[2]:#kgateway [3]:#google-kubernetes-engine ## Envoy Gateway + [Envoy Gateway][eg-home] is an [Envoy][envoy-org] subproject for managing Envoy-based application gateways. The supported APIs and fields of the Gateway API are outlined [here][eg-supported]. Use the [quickstart][eg-quickstart] to @@ -24,15 +25,15 @@ Issue](https://github.com/envoyproxy/gateway/issues/4423). [eg-supported]:https://gateway.envoyproxy.io/docs/tasks/quickstart/ [eg-quickstart]:https://gateway.envoyproxy.io/docs/tasks/quickstart -## Gloo k8sgateway +## Kgateway -[Gloo k8sgateway](https://k8sgateway.io/) is a feature-rich, Kubernetes-native -ingress controller and next-generation API gateway. Gloo k8sgateway brings the +[Kgateway](https://kgateway.dev/) is a feature-rich, Kubernetes-native +ingress controller and next-generation API gateway. Kgateway brings the full power and community support of Gateway API to its existing control-plane implementation. Progress towards supporting this project is tracked with a [GitHub -Issue](https://github.com/k8sgateway/k8sgateway/issues/10411). +Issue](https://github.com/kgateway-dev/kgateway/issues/10411). ## Google Kubernetes Engine @@ -53,4 +54,3 @@ Issue](https://github.com/GoogleCloudPlatform/gke-gateway-api/issues/20). [gke-gateway]:https://cloud.google.com/kubernetes-engine/docs/concepts/gateway-api [gke-gateway-deploy]:https://cloud.google.com/kubernetes-engine/docs/how-to/deploying-gateways [gke-multi-cluster-gateway]:https://cloud.google.com/kubernetes-engine/docs/how-to/deploying-multi-cluster-gateways - From bdcfcf0e65442465a89842729bc23e361bb0b6ce Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Tue, 11 Feb 2025 17:07:58 -0500 Subject: [PATCH 012/260] Fix: Go Mod Imports (#318) * Replaces default API group name with inference.networking.x-k8s.io Signed-off-by: Daneyon Hansen * Renames go mod prefix to sigs.k8s.io Signed-off-by: Daneyon Hansen --------- Signed-off-by: Daneyon Hansen --- api/doc.go | 17 +++++++++ api/v1alpha1/doc.go | 23 ++++++++++++ .../api/v1alpha1/extension.go | 2 +- .../api/v1alpha1/extensionconnection.go | 2 +- .../api/v1alpha1/inferencemodel.go | 2 +- .../api/v1alpha1/inferencemodelspec.go | 2 +- .../api/v1alpha1/inferencepool.go | 2 +- .../api/v1alpha1/inferencepoolspec.go | 2 +- client-go/applyconfiguration/utils.go | 8 ++--- client-go/clientset/versioned/clientset.go | 16 ++++----- .../versioned/fake/clientset_generated.go | 14 ++++---- .../clientset/versioned/fake/register.go | 4 +-- .../clientset/versioned/scheme/register.go | 4 +-- .../typed/api/v1alpha1/api_client.go | 36 +++++++++---------- .../api/v1alpha1/fake/fake_api_client.go | 10 +++--- .../api/v1alpha1/fake/fake_inferencemodel.go | 10 +++--- .../api/v1alpha1/fake/fake_inferencepool.go | 10 +++--- .../typed/api/v1alpha1/inferencemodel.go | 8 ++--- .../typed/api/v1alpha1/inferencepool.go | 8 ++--- .../externalversions/api/interface.go | 4 +-- .../api/v1alpha1/inferencemodel.go | 12 +++---- .../api/v1alpha1/inferencepool.go | 12 +++---- .../api/v1alpha1/interface.go | 2 +- .../informers/externalversions/factory.go | 10 +++--- .../informers/externalversions/generic.go | 8 ++--- .../internalinterfaces/factory_interfaces.go | 2 +- .../listers/api/v1alpha1/inferencemodel.go | 2 +- .../listers/api/v1alpha1/inferencepool.go | 2 +- go.mod | 2 +- hack/update-codegen.sh | 2 +- pkg/ext-proc/backend/datastore.go | 4 +-- pkg/ext-proc/backend/datastore_test.go | 2 +- .../backend/endpointslice_reconciler.go | 4 +-- .../backend/endpointslice_reconcilier_test.go | 2 +- pkg/ext-proc/backend/fake.go | 2 +- .../backend/inferencemodel_reconciler.go | 4 +-- .../backend/inferencemodel_reconciler_test.go | 2 +- .../backend/inferencepool_reconciler.go | 2 +- .../backend/inferencepool_reconciler_test.go | 2 +- pkg/ext-proc/backend/provider.go | 4 +-- pkg/ext-proc/backend/vllm/metrics.go | 4 +-- pkg/ext-proc/backend/vllm/metrics_test.go | 2 +- pkg/ext-proc/handlers/request.go | 6 ++-- pkg/ext-proc/handlers/response.go | 2 +- pkg/ext-proc/handlers/server.go | 10 +++--- pkg/ext-proc/health.go | 2 +- pkg/ext-proc/main.go | 10 +++--- pkg/ext-proc/scheduling/filter.go | 4 +-- pkg/ext-proc/scheduling/filter_test.go | 2 +- pkg/ext-proc/scheduling/scheduler.go | 4 +-- pkg/ext-proc/server/runserver.go | 6 ++-- pkg/ext-proc/test/benchmark/benchmark.go | 8 ++--- pkg/ext-proc/test/utils.go | 8 ++--- test/e2e/e2e_suite_test.go | 4 +-- test/e2e/e2e_test.go | 4 +-- test/integration/hermetic_test.go | 8 ++--- test/utils/utils.go | 2 +- test/utils/wrappers.go | 2 +- 58 files changed, 197 insertions(+), 157 deletions(-) create mode 100644 api/doc.go create mode 100644 api/v1alpha1/doc.go diff --git a/api/doc.go b/api/doc.go new file mode 100644 index 00000000..c91adb92 --- /dev/null +++ b/api/doc.go @@ -0,0 +1,17 @@ +/* +Copyright 2024 The Kubernetes 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 api diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go new file mode 100644 index 00000000..8e970ced --- /dev/null +++ b/api/v1alpha1/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2024 The Kubernetes 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 v1alpha1 contains API Schema definitions for the +// inference.networking.x-k8s.io API group. +// +// +k8s:openapi-gen=true +// +kubebuilder:object:generate=true +// +groupName=inference.networking.x-k8s.io +package v1alpha1 diff --git a/client-go/applyconfiguration/api/v1alpha1/extension.go b/client-go/applyconfiguration/api/v1alpha1/extension.go index 27807448..4213af88 100644 --- a/client-go/applyconfiguration/api/v1alpha1/extension.go +++ b/client-go/applyconfiguration/api/v1alpha1/extension.go @@ -18,7 +18,7 @@ limitations under the License. package v1alpha1 import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // ExtensionApplyConfiguration represents a declarative configuration of the Extension type for use diff --git a/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go b/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go index be9eeaa1..ff8752a9 100644 --- a/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go +++ b/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go @@ -18,7 +18,7 @@ limitations under the License. package v1alpha1 import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // ExtensionConnectionApplyConfiguration represents a declarative configuration of the ExtensionConnection type for use diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go b/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go index b6201467..d2a5b2b4 100644 --- a/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go +++ b/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go @@ -39,7 +39,7 @@ func InferenceModel(name, namespace string) *InferenceModelApplyConfiguration { b.WithName(name) b.WithNamespace(namespace) b.WithKind("InferenceModel") - b.WithAPIVersion("api/v1alpha1") + b.WithAPIVersion("inference.networking.x-k8s.io/v1alpha1") return b } diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go b/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go index 9bbdda06..2b1a4cbf 100644 --- a/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go +++ b/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go @@ -18,7 +18,7 @@ limitations under the License. package v1alpha1 import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // InferenceModelSpecApplyConfiguration represents a declarative configuration of the InferenceModelSpec type for use diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencepool.go b/client-go/applyconfiguration/api/v1alpha1/inferencepool.go index a7f3ed6d..2940143e 100644 --- a/client-go/applyconfiguration/api/v1alpha1/inferencepool.go +++ b/client-go/applyconfiguration/api/v1alpha1/inferencepool.go @@ -39,7 +39,7 @@ func InferencePool(name, namespace string) *InferencePoolApplyConfiguration { b.WithName(name) b.WithNamespace(namespace) b.WithKind("InferencePool") - b.WithAPIVersion("api/v1alpha1") + b.WithAPIVersion("inference.networking.x-k8s.io/v1alpha1") return b } diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go b/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go index e132f74b..5f69a154 100644 --- a/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go +++ b/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go @@ -18,7 +18,7 @@ limitations under the License. package v1alpha1 import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // InferencePoolSpecApplyConfiguration represents a declarative configuration of the InferencePoolSpec type for use diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index 1a71b674..677fa6e3 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -18,19 +18,19 @@ limitations under the License. package applyconfiguration import ( - v1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - internal "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/internal" runtime "k8s.io/apimachinery/pkg/runtime" schema "k8s.io/apimachinery/pkg/runtime/schema" testing "k8s.io/client-go/testing" + v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" + internal "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/internal" ) // ForKind returns an apply configuration type for the given GroupVersionKind, or nil if no // apply configuration type exists for the given GroupVersionKind. func ForKind(kind schema.GroupVersionKind) interface{} { switch kind { - // Group=api, Version=v1alpha1 + // Group=inference.networking.x-k8s.io, Version=v1alpha1 case v1alpha1.SchemeGroupVersion.WithKind("EndpointPickerConfig"): return &apiv1alpha1.EndpointPickerConfigApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("Extension"): diff --git a/client-go/clientset/versioned/clientset.go b/client-go/clientset/versioned/clientset.go index 18e3236a..b7ebc1d8 100644 --- a/client-go/clientset/versioned/clientset.go +++ b/client-go/clientset/versioned/clientset.go @@ -21,26 +21,26 @@ import ( fmt "fmt" http "net/http" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" discovery "k8s.io/client-go/discovery" rest "k8s.io/client-go/rest" flowcontrol "k8s.io/client-go/util/flowcontrol" + inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" ) type Interface interface { Discovery() discovery.DiscoveryInterface - ApiV1alpha1() apiv1alpha1.ApiV1alpha1Interface + InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface } // Clientset contains the clients for groups. type Clientset struct { *discovery.DiscoveryClient - apiV1alpha1 *apiv1alpha1.ApiV1alpha1Client + inferenceV1alpha1 *inferencev1alpha1.InferenceV1alpha1Client } -// ApiV1alpha1 retrieves the ApiV1alpha1Client -func (c *Clientset) ApiV1alpha1() apiv1alpha1.ApiV1alpha1Interface { - return c.apiV1alpha1 +// InferenceV1alpha1 retrieves the InferenceV1alpha1Client +func (c *Clientset) InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface { + return c.inferenceV1alpha1 } // Discovery retrieves the DiscoveryClient @@ -87,7 +87,7 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, var cs Clientset var err error - cs.apiV1alpha1, err = apiv1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) + cs.inferenceV1alpha1, err = inferencev1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) if err != nil { return nil, err } @@ -112,7 +112,7 @@ func NewForConfigOrDie(c *rest.Config) *Clientset { // New creates a new Clientset for the given RESTClient. func New(c rest.Interface) *Clientset { var cs Clientset - cs.apiV1alpha1 = apiv1alpha1.New(c) + cs.inferenceV1alpha1 = inferencev1alpha1.New(c) cs.DiscoveryClient = discovery.NewDiscoveryClient(c) return &cs diff --git a/client-go/clientset/versioned/fake/clientset_generated.go b/client-go/clientset/versioned/fake/clientset_generated.go index dda29ec6..1e54db31 100644 --- a/client-go/clientset/versioned/fake/clientset_generated.go +++ b/client-go/clientset/versioned/fake/clientset_generated.go @@ -18,15 +18,15 @@ limitations under the License. package fake import ( - applyconfiguration "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration" - clientset "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" - fakeapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1/fake" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/discovery" fakediscovery "k8s.io/client-go/discovery/fake" "k8s.io/client-go/testing" + applyconfiguration "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration" + clientset "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" + inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" + fakeinferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1/fake" ) // NewSimpleClientset returns a clientset that will respond with the provided objects. @@ -115,7 +115,7 @@ var ( _ testing.FakeClient = &Clientset{} ) -// ApiV1alpha1 retrieves the ApiV1alpha1Client -func (c *Clientset) ApiV1alpha1() apiv1alpha1.ApiV1alpha1Interface { - return &fakeapiv1alpha1.FakeApiV1alpha1{Fake: &c.Fake} +// InferenceV1alpha1 retrieves the InferenceV1alpha1Client +func (c *Clientset) InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface { + return &fakeinferencev1alpha1.FakeInferenceV1alpha1{Fake: &c.Fake} } diff --git a/client-go/clientset/versioned/fake/register.go b/client-go/clientset/versioned/fake/register.go index f252a096..b72a8ce3 100644 --- a/client-go/clientset/versioned/fake/register.go +++ b/client-go/clientset/versioned/fake/register.go @@ -18,19 +18,19 @@ limitations under the License. package fake import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" schema "k8s.io/apimachinery/pkg/runtime/schema" serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) var scheme = runtime.NewScheme() var codecs = serializer.NewCodecFactory(scheme) var localSchemeBuilder = runtime.SchemeBuilder{ - apiv1alpha1.AddToScheme, + inferencev1alpha1.AddToScheme, } // AddToScheme adds all types of this clientset into the given scheme. This allows composition diff --git a/client-go/clientset/versioned/scheme/register.go b/client-go/clientset/versioned/scheme/register.go index 6e243827..c4c06158 100644 --- a/client-go/clientset/versioned/scheme/register.go +++ b/client-go/clientset/versioned/scheme/register.go @@ -18,19 +18,19 @@ limitations under the License. package scheme import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" schema "k8s.io/apimachinery/pkg/runtime/schema" serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) var Scheme = runtime.NewScheme() var Codecs = serializer.NewCodecFactory(Scheme) var ParameterCodec = runtime.NewParameterCodec(Scheme) var localSchemeBuilder = runtime.SchemeBuilder{ - apiv1alpha1.AddToScheme, + inferencev1alpha1.AddToScheme, } // AddToScheme adds all types of this clientset into the given scheme. This allows composition diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go b/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go index 84a4a0bb..8cc8a643 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go +++ b/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go @@ -20,34 +20,34 @@ package v1alpha1 import ( http "net/http" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - scheme "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" rest "k8s.io/client-go/rest" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" ) -type ApiV1alpha1Interface interface { +type InferenceV1alpha1Interface interface { RESTClient() rest.Interface InferenceModelsGetter InferencePoolsGetter } -// ApiV1alpha1Client is used to interact with features provided by the api group. -type ApiV1alpha1Client struct { +// InferenceV1alpha1Client is used to interact with features provided by the inference.networking.x-k8s.io group. +type InferenceV1alpha1Client struct { restClient rest.Interface } -func (c *ApiV1alpha1Client) InferenceModels(namespace string) InferenceModelInterface { +func (c *InferenceV1alpha1Client) InferenceModels(namespace string) InferenceModelInterface { return newInferenceModels(c, namespace) } -func (c *ApiV1alpha1Client) InferencePools(namespace string) InferencePoolInterface { +func (c *InferenceV1alpha1Client) InferencePools(namespace string) InferencePoolInterface { return newInferencePools(c, namespace) } -// NewForConfig creates a new ApiV1alpha1Client for the given config. +// NewForConfig creates a new InferenceV1alpha1Client for the given config. // NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), // where httpClient was generated with rest.HTTPClientFor(c). -func NewForConfig(c *rest.Config) (*ApiV1alpha1Client, error) { +func NewForConfig(c *rest.Config) (*InferenceV1alpha1Client, error) { config := *c if err := setConfigDefaults(&config); err != nil { return nil, err @@ -59,9 +59,9 @@ func NewForConfig(c *rest.Config) (*ApiV1alpha1Client, error) { return NewForConfigAndClient(&config, httpClient) } -// NewForConfigAndClient creates a new ApiV1alpha1Client for the given config and http client. +// NewForConfigAndClient creates a new InferenceV1alpha1Client for the given config and http client. // Note the http client provided takes precedence over the configured transport values. -func NewForConfigAndClient(c *rest.Config, h *http.Client) (*ApiV1alpha1Client, error) { +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*InferenceV1alpha1Client, error) { config := *c if err := setConfigDefaults(&config); err != nil { return nil, err @@ -70,12 +70,12 @@ func NewForConfigAndClient(c *rest.Config, h *http.Client) (*ApiV1alpha1Client, if err != nil { return nil, err } - return &ApiV1alpha1Client{client}, nil + return &InferenceV1alpha1Client{client}, nil } -// NewForConfigOrDie creates a new ApiV1alpha1Client for the given config and +// NewForConfigOrDie creates a new InferenceV1alpha1Client for the given config and // panics if there is an error in the config. -func NewForConfigOrDie(c *rest.Config) *ApiV1alpha1Client { +func NewForConfigOrDie(c *rest.Config) *InferenceV1alpha1Client { client, err := NewForConfig(c) if err != nil { panic(err) @@ -83,9 +83,9 @@ func NewForConfigOrDie(c *rest.Config) *ApiV1alpha1Client { return client } -// New creates a new ApiV1alpha1Client for the given RESTClient. -func New(c rest.Interface) *ApiV1alpha1Client { - return &ApiV1alpha1Client{c} +// New creates a new InferenceV1alpha1Client for the given RESTClient. +func New(c rest.Interface) *InferenceV1alpha1Client { + return &InferenceV1alpha1Client{c} } func setConfigDefaults(config *rest.Config) error { @@ -103,7 +103,7 @@ func setConfigDefaults(config *rest.Config) error { // RESTClient returns a RESTClient that is used to communicate // with API server by this client implementation. -func (c *ApiV1alpha1Client) RESTClient() rest.Interface { +func (c *InferenceV1alpha1Client) RESTClient() rest.Interface { if c == nil { return nil } diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go index d5dbc1a8..1dee0f20 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go +++ b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go @@ -18,26 +18,26 @@ limitations under the License. package fake import ( - v1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" rest "k8s.io/client-go/rest" testing "k8s.io/client-go/testing" + v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" ) -type FakeApiV1alpha1 struct { +type FakeInferenceV1alpha1 struct { *testing.Fake } -func (c *FakeApiV1alpha1) InferenceModels(namespace string) v1alpha1.InferenceModelInterface { +func (c *FakeInferenceV1alpha1) InferenceModels(namespace string) v1alpha1.InferenceModelInterface { return newFakeInferenceModels(c, namespace) } -func (c *FakeApiV1alpha1) InferencePools(namespace string) v1alpha1.InferencePoolInterface { +func (c *FakeInferenceV1alpha1) InferencePools(namespace string) v1alpha1.InferencePoolInterface { return newFakeInferencePools(c, namespace) } // RESTClient returns a RESTClient that is used to communicate // with API server by this client implementation. -func (c *FakeApiV1alpha1) RESTClient() rest.Interface { +func (c *FakeInferenceV1alpha1) RESTClient() rest.Interface { var ret *rest.RESTClient return ret } diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go index e33b311d..44007ae7 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go +++ b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go @@ -18,19 +18,19 @@ limitations under the License. package fake import ( - v1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - typedapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" gentype "k8s.io/client-go/gentype" + v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" + typedapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" ) // fakeInferenceModels implements InferenceModelInterface type fakeInferenceModels struct { *gentype.FakeClientWithListAndApply[*v1alpha1.InferenceModel, *v1alpha1.InferenceModelList, *apiv1alpha1.InferenceModelApplyConfiguration] - Fake *FakeApiV1alpha1 + Fake *FakeInferenceV1alpha1 } -func newFakeInferenceModels(fake *FakeApiV1alpha1, namespace string) typedapiv1alpha1.InferenceModelInterface { +func newFakeInferenceModels(fake *FakeInferenceV1alpha1, namespace string) typedapiv1alpha1.InferenceModelInterface { return &fakeInferenceModels{ gentype.NewFakeClientWithListAndApply[*v1alpha1.InferenceModel, *v1alpha1.InferenceModelList, *apiv1alpha1.InferenceModelApplyConfiguration]( fake.Fake, diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go index 92bc5cbe..cd0764aa 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go +++ b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go @@ -18,19 +18,19 @@ limitations under the License. package fake import ( - v1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - typedapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" gentype "k8s.io/client-go/gentype" + v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" + typedapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" ) // fakeInferencePools implements InferencePoolInterface type fakeInferencePools struct { *gentype.FakeClientWithListAndApply[*v1alpha1.InferencePool, *v1alpha1.InferencePoolList, *apiv1alpha1.InferencePoolApplyConfiguration] - Fake *FakeApiV1alpha1 + Fake *FakeInferenceV1alpha1 } -func newFakeInferencePools(fake *FakeApiV1alpha1, namespace string) typedapiv1alpha1.InferencePoolInterface { +func newFakeInferencePools(fake *FakeInferenceV1alpha1, namespace string) typedapiv1alpha1.InferencePoolInterface { return &fakeInferencePools{ gentype.NewFakeClientWithListAndApply[*v1alpha1.InferencePool, *v1alpha1.InferencePoolList, *apiv1alpha1.InferencePoolApplyConfiguration]( fake.Fake, diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go index 1f5315ad..4c7c5941 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go +++ b/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go @@ -20,13 +20,13 @@ package v1alpha1 import ( context "context" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - applyconfigurationapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - scheme "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" types "k8s.io/apimachinery/pkg/types" watch "k8s.io/apimachinery/pkg/watch" gentype "k8s.io/client-go/gentype" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + applyconfigurationapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" + scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" ) // InferenceModelsGetter has a method to return a InferenceModelInterface. @@ -59,7 +59,7 @@ type inferenceModels struct { } // newInferenceModels returns a InferenceModels -func newInferenceModels(c *ApiV1alpha1Client, namespace string) *inferenceModels { +func newInferenceModels(c *InferenceV1alpha1Client, namespace string) *inferenceModels { return &inferenceModels{ gentype.NewClientWithListAndApply[*apiv1alpha1.InferenceModel, *apiv1alpha1.InferenceModelList, *applyconfigurationapiv1alpha1.InferenceModelApplyConfiguration]( "inferencemodels", diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go index 46a2b378..9af91801 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go +++ b/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go @@ -20,13 +20,13 @@ package v1alpha1 import ( context "context" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - applyconfigurationapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - scheme "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" types "k8s.io/apimachinery/pkg/types" watch "k8s.io/apimachinery/pkg/watch" gentype "k8s.io/client-go/gentype" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + applyconfigurationapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" + scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" ) // InferencePoolsGetter has a method to return a InferencePoolInterface. @@ -59,7 +59,7 @@ type inferencePools struct { } // newInferencePools returns a InferencePools -func newInferencePools(c *ApiV1alpha1Client, namespace string) *inferencePools { +func newInferencePools(c *InferenceV1alpha1Client, namespace string) *inferencePools { return &inferencePools{ gentype.NewClientWithListAndApply[*apiv1alpha1.InferencePool, *apiv1alpha1.InferencePoolList, *applyconfigurationapiv1alpha1.InferencePoolApplyConfiguration]( "inferencepools", diff --git a/client-go/informers/externalversions/api/interface.go b/client-go/informers/externalversions/api/interface.go index 6ca4f9da..fbf5ba09 100644 --- a/client-go/informers/externalversions/api/interface.go +++ b/client-go/informers/externalversions/api/interface.go @@ -18,8 +18,8 @@ limitations under the License. package api import ( - v1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api/v1alpha1" - internalinterfaces "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" + v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api/v1alpha1" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" ) // Interface provides access to each of this group's versions. diff --git a/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go b/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go index f887ff4a..a1522e48 100644 --- a/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go +++ b/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go @@ -21,14 +21,14 @@ import ( context "context" time "time" - gatewayapiinferenceextensionapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - versioned "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - internalinterfaces "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" watch "k8s.io/apimachinery/pkg/watch" cache "k8s.io/client-go/tools/cache" + gatewayapiinferenceextensionapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha1" ) // InferenceModelInformer provides access to a shared informer and lister for @@ -61,13 +61,13 @@ func NewFilteredInferenceModelInformer(client versioned.Interface, namespace str if tweakListOptions != nil { tweakListOptions(&options) } - return client.ApiV1alpha1().InferenceModels(namespace).List(context.TODO(), options) + return client.InferenceV1alpha1().InferenceModels(namespace).List(context.TODO(), options) }, WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { if tweakListOptions != nil { tweakListOptions(&options) } - return client.ApiV1alpha1().InferenceModels(namespace).Watch(context.TODO(), options) + return client.InferenceV1alpha1().InferenceModels(namespace).Watch(context.TODO(), options) }, }, &gatewayapiinferenceextensionapiv1alpha1.InferenceModel{}, diff --git a/client-go/informers/externalversions/api/v1alpha1/inferencepool.go b/client-go/informers/externalversions/api/v1alpha1/inferencepool.go index 2311a025..27f2d29e 100644 --- a/client-go/informers/externalversions/api/v1alpha1/inferencepool.go +++ b/client-go/informers/externalversions/api/v1alpha1/inferencepool.go @@ -21,14 +21,14 @@ import ( context "context" time "time" - gatewayapiinferenceextensionapiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - versioned "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - internalinterfaces "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" watch "k8s.io/apimachinery/pkg/watch" cache "k8s.io/client-go/tools/cache" + gatewayapiinferenceextensionapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha1" ) // InferencePoolInformer provides access to a shared informer and lister for @@ -61,13 +61,13 @@ func NewFilteredInferencePoolInformer(client versioned.Interface, namespace stri if tweakListOptions != nil { tweakListOptions(&options) } - return client.ApiV1alpha1().InferencePools(namespace).List(context.TODO(), options) + return client.InferenceV1alpha1().InferencePools(namespace).List(context.TODO(), options) }, WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { if tweakListOptions != nil { tweakListOptions(&options) } - return client.ApiV1alpha1().InferencePools(namespace).Watch(context.TODO(), options) + return client.InferenceV1alpha1().InferencePools(namespace).Watch(context.TODO(), options) }, }, &gatewayapiinferenceextensionapiv1alpha1.InferencePool{}, diff --git a/client-go/informers/externalversions/api/v1alpha1/interface.go b/client-go/informers/externalversions/api/v1alpha1/interface.go index 9ba07025..3ea6d988 100644 --- a/client-go/informers/externalversions/api/v1alpha1/interface.go +++ b/client-go/informers/externalversions/api/v1alpha1/interface.go @@ -18,7 +18,7 @@ limitations under the License. package v1alpha1 import ( - internalinterfaces "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" ) // Interface provides access to all the informers in this group version. diff --git a/client-go/informers/externalversions/factory.go b/client-go/informers/externalversions/factory.go index 39c96068..c06ea464 100644 --- a/client-go/informers/externalversions/factory.go +++ b/client-go/informers/externalversions/factory.go @@ -22,13 +22,13 @@ import ( sync "sync" time "time" - versioned "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - api "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api" - internalinterfaces "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" schema "k8s.io/apimachinery/pkg/runtime/schema" cache "k8s.io/client-go/tools/cache" + versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" + api "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" ) // SharedInformerOption defines the functional option type for SharedInformerFactory. @@ -253,9 +253,9 @@ type SharedInformerFactory interface { // client. InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer - Api() api.Interface + Inference() api.Interface } -func (f *sharedInformerFactory) Api() api.Interface { +func (f *sharedInformerFactory) Inference() api.Interface { return api.New(f, f.namespace, f.tweakListOptions) } diff --git a/client-go/informers/externalversions/generic.go b/client-go/informers/externalversions/generic.go index a5f15f73..672998f5 100644 --- a/client-go/informers/externalversions/generic.go +++ b/client-go/informers/externalversions/generic.go @@ -20,9 +20,9 @@ package externalversions import ( fmt "fmt" - v1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" schema "k8s.io/apimachinery/pkg/runtime/schema" cache "k8s.io/client-go/tools/cache" + v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // GenericInformer is type of SharedIndexInformer which will locate and delegate to other @@ -51,11 +51,11 @@ func (f *genericInformer) Lister() cache.GenericLister { // TODO extend this to unknown resources with a client pool func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { - // Group=api, Version=v1alpha1 + // Group=inference.networking.x-k8s.io, Version=v1alpha1 case v1alpha1.SchemeGroupVersion.WithResource("inferencemodels"): - return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().InferenceModels().Informer()}, nil + return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha1().InferenceModels().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("inferencepools"): - return &genericInformer{resource: resource.GroupResource(), informer: f.Api().V1alpha1().InferencePools().Informer()}, nil + return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha1().InferencePools().Informer()}, nil } diff --git a/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go b/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go index 488aca6f..5b70862a 100644 --- a/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go +++ b/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -20,10 +20,10 @@ package internalinterfaces import ( time "time" - versioned "inference.networking.x-k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" cache "k8s.io/client-go/tools/cache" + versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" ) // NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer. diff --git a/client-go/listers/api/v1alpha1/inferencemodel.go b/client-go/listers/api/v1alpha1/inferencemodel.go index b0c33b61..b4342842 100644 --- a/client-go/listers/api/v1alpha1/inferencemodel.go +++ b/client-go/listers/api/v1alpha1/inferencemodel.go @@ -18,10 +18,10 @@ limitations under the License. package v1alpha1 import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" labels "k8s.io/apimachinery/pkg/labels" listers "k8s.io/client-go/listers" cache "k8s.io/client-go/tools/cache" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // InferenceModelLister helps list InferenceModels. diff --git a/client-go/listers/api/v1alpha1/inferencepool.go b/client-go/listers/api/v1alpha1/inferencepool.go index 0b0c1d6e..387daf39 100644 --- a/client-go/listers/api/v1alpha1/inferencepool.go +++ b/client-go/listers/api/v1alpha1/inferencepool.go @@ -18,10 +18,10 @@ limitations under the License. package v1alpha1 import ( - apiv1alpha1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" labels "k8s.io/apimachinery/pkg/labels" listers "k8s.io/client-go/listers" cache "k8s.io/client-go/tools/cache" + apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // InferencePoolLister helps list InferencePools. diff --git a/go.mod b/go.mod index d774d6bd..c89080ae 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module inference.networking.x-k8s.io/gateway-api-inference-extension +module sigs.k8s.io/gateway-api-inference-extension go 1.23.0 diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index cfe75f81..c825507b 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -23,7 +23,7 @@ echo "$SCRIPT_ROOT script" CODEGEN_PKG=${2:-bin} echo $CODEGEN_PKG source "${CODEGEN_PKG}/kube_codegen.sh" -THIS_PKG="inference.networking.x-k8s.io/gateway-api-inference-extension" +THIS_PKG="sigs.k8s.io/gateway-api-inference-extension" kube::codegen::gen_helpers \ diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/backend/datastore.go index b466a2ed..3208be26 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/backend/datastore.go @@ -5,10 +5,10 @@ import ( "math/rand" "sync" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" corev1 "k8s.io/api/core/v1" "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func NewK8sDataStore(options ...K8sDatastoreOption) *K8sDatastore { diff --git a/pkg/ext-proc/backend/datastore_test.go b/pkg/ext-proc/backend/datastore_test.go index 323b3bb0..0fc5da1a 100644 --- a/pkg/ext-proc/backend/datastore_test.go +++ b/pkg/ext-proc/backend/datastore_test.go @@ -3,8 +3,8 @@ package backend import ( "testing" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) func TestHasSynced(t *testing.T) { diff --git a/pkg/ext-proc/backend/endpointslice_reconciler.go b/pkg/ext-proc/backend/endpointslice_reconciler.go index a2a9790f..ebc182b8 100644 --- a/pkg/ext-proc/backend/endpointslice_reconciler.go +++ b/pkg/ext-proc/backend/endpointslice_reconciler.go @@ -5,8 +5,6 @@ import ( "strconv" "time" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" discoveryv1 "k8s.io/api/discovery/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/record" @@ -15,6 +13,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) var ( diff --git a/pkg/ext-proc/backend/endpointslice_reconcilier_test.go b/pkg/ext-proc/backend/endpointslice_reconcilier_test.go index e3c927ba..9a3d55d8 100644 --- a/pkg/ext-proc/backend/endpointslice_reconcilier_test.go +++ b/pkg/ext-proc/backend/endpointslice_reconcilier_test.go @@ -4,9 +4,9 @@ import ( "sync" "testing" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" v1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) var ( diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index c4545497..8c028b77 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -3,8 +3,8 @@ package backend import ( "context" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) type FakePodMetricsClient struct { diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/backend/inferencemodel_reconciler.go index 1c1d2278..02394baa 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler.go @@ -3,8 +3,6 @@ package backend import ( "context" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -12,6 +10,8 @@ import ( "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type InferenceModelReconciler struct { diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index 45669a30..d0f6c36d 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -10,9 +10,9 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) var ( diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/backend/inferencepool_reconciler.go index 35a41f8f..b4cba202 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler.go @@ -3,13 +3,13 @@ package backend import ( "context" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // InferencePoolReconciler utilizes the controller runtime to reconcile Instance Gateway resources diff --git a/pkg/ext-proc/backend/inferencepool_reconciler_test.go b/pkg/ext-proc/backend/inferencepool_reconciler_test.go index f03c31cb..f16524a5 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler_test.go @@ -4,8 +4,8 @@ import ( "reflect" "testing" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) var ( diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index a9165e8f..68043d93 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -7,9 +7,9 @@ import ( "time" "go.uber.org/multierr" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index 8800868a..e3693960 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -12,9 +12,9 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" "go.uber.org/multierr" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( diff --git a/pkg/ext-proc/backend/vllm/metrics_test.go b/pkg/ext-proc/backend/vllm/metrics_test.go index e3c1449d..3d4225e8 100644 --- a/pkg/ext-proc/backend/vllm/metrics_test.go +++ b/pkg/ext-proc/backend/vllm/metrics_test.go @@ -7,7 +7,7 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" ) func TestPromToPodMetrics(t *testing.T) { diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index d98f4602..17278025 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -9,10 +9,10 @@ import ( configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/protobuf/types/known/structpb" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) // HandleRequestBody handles body of the request to the backend server, such as parsing the "model" diff --git a/pkg/ext-proc/handlers/response.go b/pkg/ext-proc/handlers/response.go index 3b8a9946..34a7219a 100644 --- a/pkg/ext-proc/handlers/response.go +++ b/pkg/ext-proc/handlers/response.go @@ -6,8 +6,8 @@ import ( configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) // HandleResponseHeaders processes response headers from the backend model server. diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index 172249b6..f27c9a15 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -8,12 +8,12 @@ import ( envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func NewServer(pp PodProvider, scheduler Scheduler, targetEndpointKey string, datastore ModelDataStore) *Server { diff --git a/pkg/ext-proc/health.go b/pkg/ext-proc/health.go index 488851eb..764992b2 100644 --- a/pkg/ext-proc/health.go +++ b/pkg/ext-proc/health.go @@ -6,8 +6,8 @@ import ( "google.golang.org/grpc/codes" healthPb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/status" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" ) type healthServer struct { diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index e126b6dd..634c3581 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -11,11 +11,6 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" - runserver "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -24,6 +19,11 @@ import ( klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" ) const ( diff --git a/pkg/ext-proc/scheduling/filter.go b/pkg/ext-proc/scheduling/filter.go index d431b076..fc016882 100644 --- a/pkg/ext-proc/scheduling/filter.go +++ b/pkg/ext-proc/scheduling/filter.go @@ -4,9 +4,9 @@ import ( "errors" "math" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type Filter interface { diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/ext-proc/scheduling/filter_test.go index d88f437c..224dc83f 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/ext-proc/scheduling/filter_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" ) func TestFilter(t *testing.T) { diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index 9fc3e663..ca896c5a 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -7,9 +7,9 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - logutil "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index bf666f1f..affb4b6c 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -7,14 +7,14 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/grpc" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" ) // ExtProcServerRunner provides methods to manage an external process server. diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index abaeedbb..f18782d6 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -10,11 +10,11 @@ import ( "github.com/bojand/ghz/runner" "github.com/jhump/protoreflect/desc" "google.golang.org/protobuf/proto" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - runserver "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" ) var ( diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index 98793b95..b91672fa 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -9,11 +9,11 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/grpc" "google.golang.org/grpc/reflection" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" klog "k8s.io/klog/v2" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" ) func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) *grpc.Server { diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index c2c1ea92..4a0dd2a8 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -26,8 +26,6 @@ import ( "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" - infextv1a1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - testutils "inference.networking.x-k8s.io/gateway-api-inference-extension/test/utils" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -40,6 +38,8 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/config" + infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + testutils "sigs.k8s.io/gateway-api-inference-extension/test/utils" ) const ( diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 8e5968fc..087097a7 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -24,10 +24,10 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" - infextv1a1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - testutils "inference.networking.x-k8s.io/gateway-api-inference-extension/test/utils" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" + infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + testutils "sigs.k8s.io/gateway-api-inference-extension/test/utils" ) var _ = ginkgo.Describe("InferencePool", func() { diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index b52cc9d7..e94be1a0 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -23,10 +23,6 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" - "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" - "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - runserver "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" - extprocutils "inference.networking.x-k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8syaml "k8s.io/apimachinery/pkg/util/yaml" @@ -34,6 +30,10 @@ import ( klog "k8s.io/klog/v2" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" + extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" "sigs.k8s.io/yaml" ) diff --git a/test/utils/utils.go b/test/utils/utils.go index 337599c3..777eadd8 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -24,7 +24,6 @@ import ( "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" - infextv1a1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -37,6 +36,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/remotecommand" "sigs.k8s.io/controller-runtime/pkg/client" + infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // DeleteClusterResources deletes all cluster-scoped objects the tests typically create. diff --git a/test/utils/wrappers.go b/test/utils/wrappers.go index 12ff856a..668a5adc 100644 --- a/test/utils/wrappers.go +++ b/test/utils/wrappers.go @@ -17,8 +17,8 @@ limitations under the License. package utils import ( - infextv1a1 "inference.networking.x-k8s.io/gateway-api-inference-extension/api/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) // InferenceModelWrapper wraps an InferenceModel. From 70b5c84dd2f46592e8ea0d60d34e995c28d19ea7 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Tue, 11 Feb 2025 17:08:05 -0500 Subject: [PATCH 013/260] Updates EPP Deployment and Release Doc/Script (#322) * Changes EPP ImagePullPolicy Signed-off-by: Daneyon Hansen * Updates release doc and script Signed-off-by: Daneyon Hansen --------- Signed-off-by: Daneyon Hansen --- .github/ISSUE_TEMPLATE/new-release.md | 7 ++++--- hack/release-quickstart.sh | 19 +++++++++++-------- pkg/manifests/ext_proc.yaml | 1 + 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/new-release.md b/.github/ISSUE_TEMPLATE/new-release.md index 6ed3df8c..ceca9f5f 100644 --- a/.github/ISSUE_TEMPLATE/new-release.md +++ b/.github/ISSUE_TEMPLATE/new-release.md @@ -34,10 +34,10 @@ This document defines the process for releasing Gateway API Inference Extension. export RC=1 ``` -4. The vLLM image tag defaults to `v0.7.1` for a release. Optionally, change the vLLM image tag. For example: +4. The vLLM image tag defaults to `0.7.2` for a release. Optionally, change the vLLM image tag. For example: ```shell - export VLLM=0.7.2 + export VLLM=0.7.3 ``` ## Release Process @@ -114,7 +114,8 @@ This document defines the process for releasing Gateway API Inference Extension. 9. Pushing the tag triggers Prow to build and publish the container image to the [staging registry][]. 10. Submit a PR against [k8s.io][] to add the staging image tag and SHA to [`k8s-staging-gateway-api-inference-extension/images.yaml`][yaml]. This will - promote the image to the production registry. **Note:** Add a link to this issue when the PR is merged. + promote the image to the production registry, e.g. `registry.k8s.io/gateway-api-inference-extension/epp:v${MAJOR}.${MINOR}.0`. + **Note:** Add a link to this issue when the PR is merged. 11. Test the steps in the tagged quickstart guide after the PR merges, for example: `https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/v0.1.0-rc.1/pkg/README.md`. 12. Create a [new release][]: 1. Choose the tag that you created for the release. diff --git a/hack/release-quickstart.sh b/hack/release-quickstart.sh index b156b160..f4701508 100755 --- a/hack/release-quickstart.sh +++ b/hack/release-quickstart.sh @@ -15,8 +15,8 @@ else RELEASE_TAG="v${MAJOR}.${MINOR}.0-rc.${RC}" fi -# vLLM image version (default to 0.7.1 if not defined) -VLLM="${VLLM:-0.7.1}" +# vLLM image version (default to 0.7.2 if not defined) +VLLM="${VLLM:-0.7.2}" echo "Using release tag: ${RELEASE_TAG}" echo "Using vLLM image version: ${VLLM}" @@ -41,12 +41,15 @@ sed -i.bak "s|kubectl apply -k https://github.com/kubernetes-sigs/gateway-api-in EXT_PROC="pkg/manifests/ext_proc.yaml" echo "Updating ${EXT_PROC} ..." -# Update any image reference for the EPP container. -# For images from registry.k8s.io: -sed -i.bak -E "s|(registry\.k8s\.io/gateway-api-inference-extension/epp:)[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$EXT_PROC" -# In case there is still any reference from us-central1-docker.pkg.dev: +# Update the EPP container tag. sed -i.bak -E "s|(us-central1-docker\.pkg\.dev/k8s-staging-images/gateway-api-inference-extension/epp:)[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$EXT_PROC" +# Update the EPP container image pull policy. +sed -i.bak '/us-central1-docker.pkg.dev\/k8s-staging-images\/gateway-api-inference-extension\/epp/ { n; s/Always/IfNotPresent/ }' "$EXT_PROC" + +# Update the EPP container registry. +sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EXT_PROC" + # ----------------------------------------------------------------------------- # Update pkg/manifests/vllm/deployment.yaml # ----------------------------------------------------------------------------- @@ -54,10 +57,10 @@ VLLM_DEPLOY="pkg/manifests/vllm/deployment.yaml" echo "Updating ${VLLM_DEPLOY} ..." # Update the vLLM image version -sed -i.bak -E "s|(vllm/vllm-openai:)[^\"[:space:]]+|\1${VLLM}|g" "$VLLM_DEPLOY" +sed -i.bak -E "s|(vllm/vllm-openai:)[^\"[:space:]]+|\1v${VLLM}|g" "$VLLM_DEPLOY" # Also change the imagePullPolicy from Always to IfNotPresent on lines containing the vLLM image. -sed -i.bak "/vllm\/vllm-openai/ s/Always/IfNotPresent/g" "$VLLM_DEPLOY" +sed -i.bak '/vllm\/vllm-openai/ { n; s/Always/IfNotPresent/ }' "$VLLM_DEPLOY" # ----------------------------------------------------------------------------- # Stage the changes diff --git a/pkg/manifests/ext_proc.yaml b/pkg/manifests/ext_proc.yaml index 4e82779e..a7dc7678 100644 --- a/pkg/manifests/ext_proc.yaml +++ b/pkg/manifests/ext_proc.yaml @@ -72,6 +72,7 @@ spec: containers: - name: inference-gateway-ext-proc image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/epp:main + imagePullPolicy: Always args: - -poolName - "vllm-llama2-7b-pool" From 4a8f04c614faabf07f6378ae01056d14b12e6f8b Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:56:22 +0000 Subject: [PATCH 014/260] Delete InferenceModels from the datastore when deletionTimestamp is set (#319) * Delete InferenceModels from the datastore when deletionTimestamp is set * Update pkg/ext-proc/backend/inferencemodel_reconciler_test.go --- .../backend/inferencemodel_reconciler.go | 4 ++ .../backend/inferencemodel_reconciler_test.go | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/backend/inferencemodel_reconciler.go index 02394baa..f0a13941 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler.go @@ -37,6 +37,10 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque } klog.Error(err, "Unable to get InferenceModel") return ctrl.Result{}, err + } else if !infModel.DeletionTimestamp.IsZero() { + klog.V(1).Infof("InferenceModel %v is marked for deletion. Removing from datastore", req.NamespacedName) + c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) + return ctrl.Result{}, nil } c.updateDatastore(infModel) diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index d0f6c36d..415358b2 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -184,6 +184,66 @@ func TestReconcile_ResourceNotFound(t *testing.T) { } } +func TestReconcile_ModelMarkedForDeletion(t *testing.T) { + // Set up the scheme. + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + + // Create an InferenceModel object. + now := metav1.Now() + existingModel := &v1alpha1.InferenceModel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-model", + Namespace: "default", + DeletionTimestamp: &now, + Finalizers: []string{"finalizer"}, + }, + Spec: v1alpha1.InferenceModelSpec{ + ModelName: "fake-model", + PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, + }, + } + + // Create a fake client with the existing model. + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() + + // Create a minimal datastore. + datastore := &K8sDatastore{ + InferenceModels: &sync.Map{}, + inferencePool: &v1alpha1.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, + }, + } + + // Create the reconciler. + reconciler := &InferenceModelReconciler{ + Client: fakeClient, + Scheme: scheme, + Record: record.NewFakeRecorder(10), + Datastore: datastore, + PoolNamespacedName: types.NamespacedName{Name: "test-pool", Namespace: "default"}, + } + + // Create a request for the existing resource. + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "existing-model", Namespace: "default"}} + + // Call Reconcile. + result, err := reconciler.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("expected no error when resource exists, got %v", err) + } + + // Check that no requeue is requested. + if result.Requeue || result.RequeueAfter != 0 { + t.Errorf("expected no requeue, got %+v", result) + } + + // Verify that the datastore was not updated. + if _, ok := datastore.InferenceModels.Load(existingModel.Spec.ModelName); ok { + t.Errorf("expected datastore to not contain model %q", existingModel.Spec.ModelName) + } +} + func TestReconcile_ResourceExists(t *testing.T) { // Set up the scheme. scheme := runtime.NewScheme() From 242b73e4cf6da33a09d96002ab6bd08936ec6855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Wed, 12 Feb 2025 23:50:21 +0100 Subject: [PATCH 015/260] Actually init logging using Zap (#267) Controllers typically use Zap these days. The only potential issue is that the flags are not compatible. This is somehow mitigated by supporting -v explicitly. --- go.mod | 3 ++- pkg/ext-proc/main.go | 31 +++++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c89080ae..d8b143ec 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/prometheus/common v0.62.0 github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 + go.uber.org/zap v1.27.0 google.golang.org/grpc v1.70.0 google.golang.org/protobuf v1.36.5 k8s.io/api v0.32.1 @@ -62,6 +63,7 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -114,7 +116,6 @@ require ( go.opentelemetry.io/otel/sdk v1.32.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.22.0 // indirect diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 634c3581..6bdaae66 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -9,6 +9,8 @@ import ( "strconv" "github.com/prometheus/client_golang/prometheus/promhttp" + uberzap "go.uber.org/zap" + "go.uber.org/zap/zapcore" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" "k8s.io/apimachinery/pkg/runtime" @@ -18,12 +20,14 @@ import ( "k8s.io/component-base/metrics/legacyregistry" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( @@ -73,6 +77,7 @@ var ( "refreshPrometheusMetricsInterval", runserver.DefaultRefreshPrometheusMetricsInterval, "interval to flush prometheus metrics") + logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") scheme = runtime.NewScheme() ) @@ -83,10 +88,13 @@ func init() { } func main() { - klog.InitFlags(nil) + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) flag.Parse() + initLogging(&opts) - ctrl.SetLogger(klog.TODO()) cfg, err := ctrl.GetConfig() if err != nil { klog.Fatalf("Failed to get rest config: %v", err) @@ -152,6 +160,25 @@ func main() { klog.Info("All components shutdown") } +func initLogging(opts *zap.Options) { + // Unless -zap-log-level is explicitly set, use -v + useV := true + flag.Visit(func(f *flag.Flag) { + if f.Name == "zap-log-level" { + useV = false + } + }) + if useV { + // See https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/log/zap#Options.Level + lvl := -1 * (*logVerbosity) + opts.Level = uberzap.NewAtomicLevelAt(zapcore.Level(int8(lvl))) + } + + logger := zap.New(zap.UseFlagOptions(opts), zap.RawZapOpts(uberzap.AddCaller())) + ctrl.SetLogger(logger) + klog.SetLogger(logger) +} + // startHealthServer starts the gRPC health probe server in a goroutine. func startHealthServer(ds *backend.K8sDatastore, port int) *grpc.Server { svr := grpc.NewServer() From 0662f1f391c4c3ec331a05d9f2ebed2bb5a845fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Thu, 13 Feb 2025 05:54:20 +0100 Subject: [PATCH 016/260] Remove fatal logging in executable code (#265) All Fatal log call are removed. Also all runnable components are now managed by controller-runtime, implementing manager.Runnable interface. --- pkg/ext-proc/internal/runnable/grpc.go | 52 +++++++ .../internal/runnable/leader_election.go | 31 ++++ pkg/ext-proc/main.go | 140 +++++++++--------- pkg/ext-proc/server/runserver.go | 75 +++++----- pkg/ext-proc/server/runserver_test.go | 21 +++ test/integration/hermetic_test.go | 22 ++- 6 files changed, 229 insertions(+), 112 deletions(-) create mode 100644 pkg/ext-proc/internal/runnable/grpc.go create mode 100644 pkg/ext-proc/internal/runnable/leader_election.go create mode 100644 pkg/ext-proc/server/runserver_test.go diff --git a/pkg/ext-proc/internal/runnable/grpc.go b/pkg/ext-proc/internal/runnable/grpc.go new file mode 100644 index 00000000..a619f788 --- /dev/null +++ b/pkg/ext-proc/internal/runnable/grpc.go @@ -0,0 +1,52 @@ +package runnable + +import ( + "context" + "fmt" + "net" + + "google.golang.org/grpc" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// GRPCServer converts the given gRPC server into a runnable. +// The server name is just being used for logging. +func GRPCServer(name string, srv *grpc.Server, port int) manager.Runnable { + return manager.RunnableFunc(func(ctx context.Context) error { + // Use "name" key as that is what manager.Server does as well. + log := ctrl.Log.WithValues("name", name) + log.Info("gRPC server starting") + + // Start listening. + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + log.Error(err, "gRPC server failed to listen") + return err + } + + log.Info("gRPC server listening", "port", port) + + // Shutdown on context closed. + // Terminate the server on context closed. + // Make sure the goroutine does not leak. + doneCh := make(chan struct{}) + defer close(doneCh) + go func() { + select { + case <-ctx.Done(): + log.Info("gRPC server shutting down") + srv.GracefulStop() + case <-doneCh: + } + }() + + // Keep serving until terminated. + if err := srv.Serve(lis); err != nil && err != grpc.ErrServerStopped { + log.Error(err, "gRPC server failed") + return err + } + log.Info("gRPC server terminated") + return nil + }) +} diff --git a/pkg/ext-proc/internal/runnable/leader_election.go b/pkg/ext-proc/internal/runnable/leader_election.go new file mode 100644 index 00000000..00dfc782 --- /dev/null +++ b/pkg/ext-proc/internal/runnable/leader_election.go @@ -0,0 +1,31 @@ +package runnable + +import "sigs.k8s.io/controller-runtime/pkg/manager" + +type leaderElection struct { + manager.Runnable + needsLeaderElection bool +} + +// LeaderElection wraps the given runnable to implement manager.LeaderElectionRunnable. +func LeaderElection(runnable manager.Runnable, needsLeaderElection bool) manager.Runnable { + return &leaderElection{ + Runnable: runnable, + needsLeaderElection: needsLeaderElection, + } +} + +// RequireLeaderElection wraps the given runnable, marking it as requiring leader election. +func RequireLeaderElection(runnable manager.Runnable) manager.Runnable { + return LeaderElection(runnable, true) +} + +// RequireLeaderElection wraps the given runnable, marking it as not requiring leader election. +func NoLeaderElection(runnable manager.Runnable) manager.Runnable { + return LeaderElection(runnable, false) +} + +// NeedLeaderElection implements manager.NeedLeaderElection interface. +func (r *leaderElection) NeedLeaderElection() bool { + return r.needsLeaderElection +} diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 6bdaae66..d51435ac 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -1,11 +1,11 @@ package main import ( - "context" "flag" "fmt" "net" "net/http" + "os" "strconv" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -21,10 +21,12 @@ import ( klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" @@ -88,6 +90,12 @@ func init() { } func main() { + if err := run(); err != nil { + os.Exit(1) + } +} + +func run() error { opts := zap.Options{ Development: true, } @@ -97,11 +105,13 @@ func main() { cfg, err := ctrl.GetConfig() if err != nil { - klog.Fatalf("Failed to get rest config: %v", err) + klog.ErrorS(err, "Failed to get rest config") + return err } // Validate flags if err := validateFlags(); err != nil { - klog.Fatalf("Failed to validate flags: %v", err) + klog.ErrorS(err, "Failed to validate flags") + return err } // Print all flag values @@ -127,37 +137,30 @@ func main() { Config: ctrl.GetConfigOrDie(), Datastore: datastore, } - serverRunner.Setup() - - // Start health and ext-proc servers in goroutines - healthSvr := startHealthServer(datastore, *grpcHealthPort) - extProcSvr := serverRunner.Start( - datastore, - &vllm.PodMetricsClientImpl{}, - ) - // Start metrics handler - metricsSvr := startMetricsHandler(*metricsPort, cfg) - - // Start manager, blocking - serverRunner.StartManager() + if err := serverRunner.Setup(); err != nil { + klog.ErrorS(err, "Failed to setup ext-proc server") + return err + } + mgr := serverRunner.Manager - // Gracefully shutdown servers - if healthSvr != nil { - klog.Info("Health server shutting down") - healthSvr.GracefulStop() + // Register health server. + if err := registerHealthServer(mgr, datastore, *grpcHealthPort); err != nil { + return err } - if extProcSvr != nil { - klog.Info("Ext-proc server shutting down") - extProcSvr.GracefulStop() + + // Register ext-proc server. + if err := mgr.Add(serverRunner.AsRunnable(datastore, &vllm.PodMetricsClientImpl{})); err != nil { + klog.ErrorS(err, "Failed to register ext-proc server") + return err } - if metricsSvr != nil { - klog.Info("Metrics server shutting down") - if err := metricsSvr.Shutdown(context.Background()); err != nil { - klog.Infof("Metrics server Shutdown: %v", err) - } + + // Register metrics handler. + if err := registerMetricsHandler(mgr, *metricsPort, cfg); err != nil { + return err } - klog.Info("All components shutdown") + // Start the manager. + return serverRunner.StartManager(ctrl.SetupSignalHandler()) } func initLogging(opts *zap.Options) { @@ -179,68 +182,69 @@ func initLogging(opts *zap.Options) { klog.SetLogger(logger) } -// startHealthServer starts the gRPC health probe server in a goroutine. -func startHealthServer(ds *backend.K8sDatastore, port int) *grpc.Server { - svr := grpc.NewServer() - healthPb.RegisterHealthServer(svr, &healthServer{datastore: ds}) - - go func() { - lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - klog.Fatalf("Health server failed to listen: %v", err) - } - klog.Infof("Health server listening on port: %d", port) - - // Blocking and will return when shutdown is complete. - if err := svr.Serve(lis); err != nil && err != grpc.ErrServerStopped { - klog.Fatalf("Health server failed: %v", err) - } - klog.Info("Health server shutting down") - }() - return svr +// registerHealthServer adds the Health gRPC server as a Runnable to the given manager. +func registerHealthServer(mgr manager.Manager, ds *backend.K8sDatastore, port int) error { + srv := grpc.NewServer() + healthPb.RegisterHealthServer(srv, &healthServer{datastore: ds}) + if err := mgr.Add( + runnable.NoLeaderElection(runnable.GRPCServer("health", srv, port))); err != nil { + klog.ErrorS(err, "Failed to register health server") + return err + } + return nil } -func startMetricsHandler(port int, cfg *rest.Config) *http.Server { +// registerMetricsHandler adds the metrics HTTP handler as a Runnable to the given manager. +func registerMetricsHandler(mgr manager.Manager, port int, cfg *rest.Config) error { metrics.Register() - var svr *http.Server - go func() { - klog.Info("Starting metrics HTTP handler ...") + // Init HTTP server. + h, err := metricsHandlerWithAuthenticationAndAuthorization(cfg) + if err != nil { + return err + } + + mux := http.NewServeMux() + mux.Handle(defaultMetricsEndpoint, h) - mux := http.NewServeMux() - mux.Handle(defaultMetricsEndpoint, metricsHandlerWithAuthenticationAndAuthorization(cfg)) + srv := &http.Server{ + Addr: net.JoinHostPort("", strconv.Itoa(port)), + Handler: mux, + } - svr = &http.Server{ - Addr: net.JoinHostPort("", strconv.Itoa(port)), - Handler: mux, - } - if err := svr.ListenAndServe(); err != http.ErrServerClosed { - klog.Fatalf("failed to start metrics HTTP handler: %v", err) - } - }() - return svr + if err := mgr.Add(&manager.Server{ + Name: "metrics", + Server: srv, + }); err != nil { + klog.ErrorS(err, "Failed to register metrics HTTP handler") + return err + } + return nil } -func metricsHandlerWithAuthenticationAndAuthorization(cfg *rest.Config) http.Handler { +func metricsHandlerWithAuthenticationAndAuthorization(cfg *rest.Config) (http.Handler, error) { h := promhttp.HandlerFor( legacyregistry.DefaultGatherer, promhttp.HandlerOpts{}, ) httpClient, err := rest.HTTPClientFor(cfg) if err != nil { - klog.Fatalf("failed to create http client for metrics auth: %v", err) + klog.ErrorS(err, "Failed to create http client for metrics auth") + return nil, err } filter, err := filters.WithAuthenticationAndAuthorization(cfg, httpClient) if err != nil { - klog.Fatalf("failed to create metrics filter for auth: %v", err) + klog.ErrorS(err, "Failed to create metrics filter for auth") + return nil, err } metricsLogger := klog.LoggerWithValues(klog.NewKlogr(), "path", defaultMetricsEndpoint) metricsAuthHandler, err := filter(metricsLogger, h) if err != nil { - klog.Fatalf("failed to create metrics auth handler: %v", err) + klog.ErrorS(err, "Failed to create metrics auth handler") + return nil, err } - return metricsAuthHandler + return metricsAuthHandler, nil } func validateFlags() error { diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index affb4b6c..71499e8f 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -1,8 +1,9 @@ package server import ( + "context" + "errors" "fmt" - "net" "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" @@ -12,8 +13,10 @@ import ( "k8s.io/client-go/rest" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" ) @@ -31,7 +34,7 @@ type ExtProcServerRunner struct { Scheme *runtime.Scheme Config *rest.Config Datastore *backend.K8sDatastore - manager ctrl.Manager + Manager ctrl.Manager } // Default values for CLI flags in main @@ -63,13 +66,13 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { } // Setup creates the reconcilers for pools, models, and endpointSlices and starts the manager. -func (r *ExtProcServerRunner) Setup() { +func (r *ExtProcServerRunner) Setup() error { // Create a new manager to manage controllers mgr, err := ctrl.NewManager(r.Config, ctrl.Options{Scheme: r.Scheme}) if err != nil { - klog.Fatalf("Failed to create controller manager: %v", err) + return fmt.Errorf("failed to create controller manager: %w", err) } - r.manager = mgr + r.Manager = mgr // Create the controllers and register them with the manager if err := (&backend.InferencePoolReconciler{ @@ -82,7 +85,7 @@ func (r *ExtProcServerRunner) Setup() { }, Record: mgr.GetEventRecorderFor("InferencePool"), }).SetupWithManager(mgr); err != nil { - klog.Fatalf("Failed setting up InferencePoolReconciler: %v", err) + return fmt.Errorf("failed setting up InferencePoolReconciler: %w", err) } if err := (&backend.InferenceModelReconciler{ @@ -95,7 +98,7 @@ func (r *ExtProcServerRunner) Setup() { }, Record: mgr.GetEventRecorderFor("InferenceModel"), }).SetupWithManager(mgr); err != nil { - klog.Fatalf("Failed setting up InferenceModelReconciler: %v", err) + return fmt.Errorf("failed setting up InferenceModelReconciler: %w", err) } if err := (&backend.EndpointSliceReconciler{ @@ -106,54 +109,50 @@ func (r *ExtProcServerRunner) Setup() { ServiceName: r.ServiceName, Zone: r.Zone, }).SetupWithManager(mgr); err != nil { - klog.Fatalf("Failed setting up EndpointSliceReconciler: %v", err) + return fmt.Errorf("failed setting up EndpointSliceReconciler: %v", err) } + return nil } -// Start starts the Envoy external processor server in a goroutine. -func (r *ExtProcServerRunner) Start( +// AsRunnable returns a Runnable that can be used to start the ext-proc gRPC server. +// The runnable implements LeaderElectionRunnable with leader election disabled. +func (r *ExtProcServerRunner) AsRunnable( podDatastore *backend.K8sDatastore, podMetricsClient backend.PodMetricsClient, -) *grpc.Server { - svr := grpc.NewServer() - - go func() { - lis, err := net.Listen("tcp", fmt.Sprintf(":%d", r.GrpcPort)) - if err != nil { - klog.Fatalf("Ext-proc server failed to listen: %v", err) - } - klog.Infof("Ext-proc server listening on port: %d", r.GrpcPort) - +) manager.Runnable { + return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { // Initialize backend provider pp := backend.NewProvider(podMetricsClient, podDatastore) if err := pp.Init(r.RefreshPodsInterval, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { - klog.Fatalf("Failed to initialize backend provider: %v", err) + klog.ErrorS(err, "Failed to initialize backend provider") + return err } - // Register ext_proc handlers + // Init the server. + srv := grpc.NewServer() extProcPb.RegisterExternalProcessorServer( - svr, + srv, handlers.NewServer(pp, scheduling.NewScheduler(pp), r.TargetEndpointKey, r.Datastore), ) - // Blocking and will return when shutdown is complete. - if err := svr.Serve(lis); err != nil && err != grpc.ErrServerStopped { - klog.Fatalf("Ext-proc server failed: %v", err) - } - klog.Info("Ext-proc server shutting down") - }() - return svr + // Forward to the gRPC runnable. + return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) + })) } -func (r *ExtProcServerRunner) StartManager() { - if r.manager == nil { - klog.Fatalf("Runner has no manager setup to run: %v", r) +func (r *ExtProcServerRunner) StartManager(ctx context.Context) error { + if r.Manager == nil { + err := errors.New("runner manager is not set") + klog.ErrorS(err, "Runner has no manager setup to run") + return err } + // Start the controller manager. Blocking and will return when shutdown is complete. - klog.Infof("Starting controller manager") - mgr := r.manager - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - klog.Fatalf("Error starting controller manager: %v", err) + klog.InfoS("Controller manager starting") + if err := r.Manager.Start(ctx); err != nil { + klog.ErrorS(err, "Error starting controller manager") + return err } - klog.Info("Controller manager shutting down") + klog.InfoS("Controller manager terminated") + return nil } diff --git a/pkg/ext-proc/server/runserver_test.go b/pkg/ext-proc/server/runserver_test.go new file mode 100644 index 00000000..df2081aa --- /dev/null +++ b/pkg/ext-proc/server/runserver_test.go @@ -0,0 +1,21 @@ +package server_test + +import ( + "testing" + + "sigs.k8s.io/controller-runtime/pkg/manager" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" +) + +func TestRunnable(t *testing.T) { + // Make sure AsRunnable() does not use leader election. + runner := server.NewDefaultExtProcServerRunner().AsRunnable(nil, nil) + r, ok := runner.(manager.LeaderElectionRunnable) + if !ok { + t.Fatal("runner is not LeaderElectionRunnable") + } + if r.NeedLeaderElection() { + t.Error("runner returned NeedLeaderElection = true, expected false") + } +} diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index e94be1a0..74c9f049 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -28,6 +28,7 @@ import ( k8syaml "k8s.io/apimachinery/pkg/util/yaml" clientgoscheme "k8s.io/client-go/kubernetes/scheme" klog "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" @@ -406,7 +407,6 @@ func TestKubeInferenceModelRequest(t *testing.T) { } func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - ps := make(backend.PodSet) pms := make(map[backend.Pod]*backend.PodMetrics) for _, pod := range pods { @@ -415,7 +415,14 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP } pmc := &backend.FakePodMetricsClient{Res: pms} - server := serverRunner.Start(backend.NewK8sDataStore(backend.WithPods(pods)), pmc) + serverCtx, stopServer := context.WithCancel(context.Background()) + go func() { + if err := serverRunner.AsRunnable( + backend.NewK8sDataStore(backend.WithPods(pods)), pmc, + ).Start(serverCtx); err != nil { + log.Fatalf("Failed to start ext-proc server: %v", err) + } + }() // Wait the reconciler to populate the datastore. time.Sleep(10 * time.Second) @@ -435,7 +442,7 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP return client, func() { cancel() conn.Close() - server.GracefulStop() + stopServer() } } @@ -447,7 +454,6 @@ func BeforeSuit() { ErrorIfCRDPathMissing: true, } cfg, err := testEnv.Start() - if err != nil { log.Fatalf("Failed to start test environment, cfg: %v error: %v", cfg, err) } @@ -469,11 +475,15 @@ func BeforeSuit() { serverRunner.Config = cfg serverRunner.Datastore = backend.NewK8sDataStore() - serverRunner.Setup() + if err := serverRunner.Setup(); err != nil { + log.Fatalf("Failed to start server runner: %v", err) + } // Start the controller manager in go routine, not blocking go func() { - serverRunner.StartManager() + if err := serverRunner.StartManager(ctrl.SetupSignalHandler()); err != nil { + log.Fatalf("Failed to start manager: %v", err) + } }() klog.Info("Setting up hermetic ExtProc server") From db21e9eaaa6222b15a624df1f9881e3f20009a37 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Thu, 13 Feb 2025 13:10:21 -0500 Subject: [PATCH 017/260] feat: Adds e2e test script (#294) * feat: Adds e2e test script Signed-off-by: Daneyon Hansen * Docs the e2e test script Signed-off-by: Daneyon Hansen --------- Signed-off-by: Daneyon Hansen --- hack/test-e2e.sh | 137 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100755 hack/test-e2e.sh diff --git a/hack/test-e2e.sh b/hack/test-e2e.sh new file mode 100755 index 00000000..716e626a --- /dev/null +++ b/hack/test-e2e.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# +# This script verifies end-to-end connectivity for an example inference extension test environment based on +# resources from the quickstart guide or e2e test framework. It can optionally launch a "curl" client pod to +# run these tests within the cluster. +# +# USAGE: ./hack/e2e-test.sh +# +# OPTIONAL ENVIRONMENT VARIABLES: +# - TIME: The duration (in seconds) for which the test will run. Defaults to 1 second. +# - CURL_POD: If set to "true", the script will use a Kubernetes pod named "curl" for making requests. +# - IP: Override the detected IP address. If not provided, the script attempts to use a Gateway based on +# the quickstart guide or an Envoy service IP based on the e2e test framework. +# - PORT: Override the detected port. If not provided, the script attempts to use a Gateway based on the +# quickstart guide or an Envoy service IP based on the e2e test framework. +# +# WHAT THE SCRIPT DOES: +# 1. Determines if there is a Gateway named "inference-gateway" in the "default" namespace. If found, it extracts the IP +# address and port from the Gateway's "llm-gw" listener. Otherwise, it falls back to the Envoy service in the "default" namespace. +# 2. Optionally checks for (or creates) a "curl" pod, ensuring it is ready to execute requests. +# 3. Loops for $TIME seconds, sending requests every 5 seconds to the /v1/completions endpoint to confirm successful connectivity. + +set -euo pipefail + +# Determine the directory of this script and build an absolute path to client.yaml. +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +CLIENT_YAML="$SCRIPT_DIR/../test/testdata/client.yaml" + +# TIME is the amount of time, in seconds, to run the test. +TIME=${TIME:-1} +# Optionally use a client curl pod for executing the curl command. +CURL_POD=${CURL_POD:-false} + +check_resource_exists() { + local type=$1 + local name=$2 + local namespace=$3 + + if kubectl get "$type" "$name" -n "$namespace" &>/dev/null; then + return 0 + else + return 1 + fi +} + +check_pod_ready() { + local pod_name=$1 + local namespace=$2 + # Check the Ready condition using jsonpath. Default to False if not found. + local ready_status + ready_status=$(kubectl get pod "$pod_name" -n "$namespace" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "False") + if [[ "$ready_status" == "True" ]]; then + return 0 + else + return 1 + fi +} + +# Try to get the Gateway's IP and the port from the listener named "llm-gw" if it exists. +if check_resource_exists "gateway" "inference-gateway" "default"; then + GATEWAY_IP=$(kubectl get gateway inference-gateway -n default -o jsonpath='{.status.addresses[0].value}') + # Use JSONPath to select the port from the listener with name "llm-gw" + GATEWAY_PORT=$(kubectl get gateway inference-gateway -n default -o jsonpath='{.spec.listeners[?(@.name=="llm-gw")].port}') +else + GATEWAY_IP="" + GATEWAY_PORT="" +fi + +if [[ -n "$GATEWAY_IP" && -n "$GATEWAY_PORT" ]]; then + echo "Using Gateway inference-gateway IP and port from listener 'llm-gw'." + IP=${IP:-$GATEWAY_IP} + PORT=${PORT:-$GATEWAY_PORT} +else + echo "Gateway inference-gateway not found or missing IP/port. Falling back to Envoy service." + # Ensure the Envoy service exists. + if ! check_resource_exists "svc" "envoy" "default"; then + echo "Error: Envoy service not found in namespace 'default'." + exit 1 + fi + IP=${IP:-$(kubectl get svc envoy -n default -o jsonpath='{.spec.clusterIP}')} + PORT=${PORT:-$(kubectl get svc envoy -n default -o jsonpath='{.spec.ports[0].port}')} +fi + +# Optionally verify that the curl pod exists and is ready. +if [[ "$CURL_POD" == "true" ]]; then + if ! check_resource_exists "pod" "curl" "default"; then + echo "Pod 'curl' not found in namespace 'default'. Applying client.yaml from $CLIENT_YAML..." + kubectl apply -f "$CLIENT_YAML" + fi + echo "Waiting for pod 'curl' to be ready..." + # Retry every 5 seconds for up to 30 seconds (6 attempts) + for i in {1..6}; do + if check_pod_ready "curl" "default"; then + echo "Pod 'curl' is now ready." + break + fi + echo "Retry attempt $i: Pod 'curl' not ready; waiting 5 seconds..." + sleep 5 + done + + if ! check_pod_ready "curl" "default"; then + echo "Error: Pod 'curl' is still not ready in namespace 'default' after 30 seconds." + exit 1 + fi +fi + +# Validate that we have a non-empty IP and PORT. +if [[ -z "$IP" ]]; then + echo "Error: Unable to determine a valid IP from either Gateway or Envoy service." + exit 1 +fi + +if [[ -z "$PORT" ]]; then + echo "Error: Unable to determine a valid port from either Gateway or Envoy service." + exit 1 +fi + +echo "Using IP: $IP" +echo "Using PORT: $PORT" + +# Run the test for the specified duration. +end=$((SECONDS + TIME)) +if [[ "$CURL_POD" == "true" ]]; then + while [ $SECONDS -lt $end ]; do + kubectl exec po/curl -- curl -i "$IP:$PORT/v1/completions" \ + -H 'Content-Type: application/json' \ + -d '{"model": "tweet-summary","prompt": "Write as if you were a critic: San Francisco","max_tokens": 100,"temperature": 0}' + sleep 5 + done +else + while [ $SECONDS -lt $end ]; do + curl -i "$IP:$PORT/v1/completions" \ + -H 'Content-Type: application/json' \ + -d '{"model": "tweet-summary","prompt": "Write as if you were a critic: San Francisco","max_tokens": 100,"temperature": 0}' + sleep 5 + done +fi From 46541d0e92a5050a194b6aaf71a80fcb3b9c3fc8 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Thu, 13 Feb 2025 12:02:20 -0700 Subject: [PATCH 018/260] Replacing endpointSlice Reconciler with a direct Pod Reconciler (#300) * reversion to pod reconciliation * adding ready check and unit tests * updating test * ablating unnecessary func * embedding ready status into update so non-ready pods are deleted * scrubbing serviceName & zone as they are obsolete * implementing pod cache flushing logic * Renaming file so merge confilcts can find the diffs easier * cleaning up messy merge conflict * nil checking short circuit * Listing fixes * feedback cleanup * log formatting and removing pods if not found * removing err to provent perma-reconciliation * removing dev image ref * cleaning up err logic --- pkg/ext-proc/backend/datastore.go | 42 ++++ .../backend/endpointslice_reconciler.go | 109 ---------- .../backend/endpointslice_reconcilier_test.go | 202 ------------------ .../backend/inferencemodel_reconciler_test.go | 21 ++ .../backend/inferencepool_reconciler.go | 12 +- pkg/ext-proc/backend/pod_reconciler.go | 80 +++++++ pkg/ext-proc/backend/pod_reconciler_test.go | 168 +++++++++++++++ pkg/ext-proc/main.go | 14 -- pkg/ext-proc/server/runserver.go | 18 +- pkg/manifests/ext_proc.yaml | 2 - 10 files changed, 324 insertions(+), 344 deletions(-) delete mode 100644 pkg/ext-proc/backend/endpointslice_reconciler.go delete mode 100644 pkg/ext-proc/backend/endpointslice_reconcilier_test.go create mode 100644 pkg/ext-proc/backend/pod_reconciler.go create mode 100644 pkg/ext-proc/backend/pod_reconciler_test.go diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/backend/datastore.go index 3208be26..be3c7f0b 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/backend/datastore.go @@ -1,12 +1,16 @@ package backend import ( + "context" "errors" "math/rand" + "strconv" "sync" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -111,3 +115,41 @@ func IsCritical(model *v1alpha1.InferenceModel) bool { } return false } + +func (ds *K8sDatastore) LabelsMatch(podLabels map[string]string) bool { + poolSelector := selectorFromInferencePoolSelector(ds.inferencePool.Spec.Selector) + podSet := labels.Set(podLabels) + return poolSelector.Matches(podSet) +} + +func (ds *K8sDatastore) flushPodsAndRefetch(ctx context.Context, ctrlClient client.Client, newServerPool *v1alpha1.InferencePool) { + podList := &corev1.PodList{} + if err := ctrlClient.List(ctx, podList, &client.ListOptions{ + LabelSelector: selectorFromInferencePoolSelector(newServerPool.Spec.Selector), + Namespace: newServerPool.Namespace, + }); err != nil { + klog.Error(err, "error listing clients") + } + ds.pods.Clear() + + for _, k8sPod := range podList.Items { + pod := Pod{ + Name: k8sPod.Name, + Address: k8sPod.Status.PodIP + ":" + strconv.Itoa(int(newServerPool.Spec.TargetPortNumber)), + } + ds.pods.Store(pod, true) + } + +} + +func selectorFromInferencePoolSelector(selector map[v1alpha1.LabelKey]v1alpha1.LabelValue) labels.Selector { + return labels.SelectorFromSet(stripLabelKeyAliasFromLabelMap(selector)) +} + +func stripLabelKeyAliasFromLabelMap(labels map[v1alpha1.LabelKey]v1alpha1.LabelValue) map[string]string { + outMap := make(map[string]string) + for k, v := range labels { + outMap[string(k)] = string(v) + } + return outMap +} diff --git a/pkg/ext-proc/backend/endpointslice_reconciler.go b/pkg/ext-proc/backend/endpointslice_reconciler.go deleted file mode 100644 index ebc182b8..00000000 --- a/pkg/ext-proc/backend/endpointslice_reconciler.go +++ /dev/null @@ -1,109 +0,0 @@ -package backend - -import ( - "context" - "strconv" - "time" - - discoveryv1 "k8s.io/api/discovery/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - klog "k8s.io/klog/v2" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" -) - -var ( - serviceOwnerLabel = "kubernetes.io/service-name" -) - -type EndpointSliceReconciler struct { - client.Client - Scheme *runtime.Scheme - Record record.EventRecorder - ServiceName string - Zone string - Datastore *K8sDatastore -} - -func (c *EndpointSliceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - inferencePool, err := c.Datastore.getInferencePool() - if err != nil { - klog.V(logutil.DEFAULT).Infof("Skipping reconciling EndpointSlice because the InferencePool is not available yet: %v", err) - return ctrl.Result{Requeue: true, RequeueAfter: time.Second}, nil - } - - klog.V(logutil.DEFAULT).Info("Reconciling EndpointSlice ", req.NamespacedName) - - endpointSlice := &discoveryv1.EndpointSlice{} - if err := c.Get(ctx, req.NamespacedName, endpointSlice); err != nil { - klog.Errorf("Unable to get EndpointSlice: %v", err) - return ctrl.Result{}, err - } - c.updateDatastore(endpointSlice, inferencePool) - - return ctrl.Result{}, nil -} - -// TODO: Support multiple endpointslices for a single service -func (c *EndpointSliceReconciler) updateDatastore( - slice *discoveryv1.EndpointSlice, - inferencePool *v1alpha1.InferencePool) { - podMap := make(map[Pod]bool) - - for _, endpoint := range slice.Endpoints { - klog.V(logutil.DEFAULT).Infof("Zone: %v \n endpoint: %+v \n", c.Zone, endpoint) - if c.validPod(endpoint) { - pod := Pod{ - Name: endpoint.TargetRef.Name, - Address: endpoint.Addresses[0] + ":" + strconv.Itoa(int(inferencePool.Spec.TargetPortNumber)), - } - podMap[pod] = true - klog.V(logutil.DEFAULT).Infof("Storing pod %v", pod) - c.Datastore.pods.Store(pod, true) - } - } - - removeOldPods := func(k, v any) bool { - pod, ok := k.(Pod) - if !ok { - klog.Errorf("Unable to cast key to Pod: %v", k) - return false - } - if _, ok := podMap[pod]; !ok { - klog.V(logutil.DEFAULT).Infof("Removing pod %v", pod) - c.Datastore.pods.Delete(pod) - } - return true - } - c.Datastore.pods.Range(removeOldPods) -} - -func (c *EndpointSliceReconciler) SetupWithManager(mgr ctrl.Manager) error { - ownsEndPointSlice := func(object client.Object) bool { - // Check if the object is an EndpointSlice - endpointSlice, ok := object.(*discoveryv1.EndpointSlice) - if !ok { - return false - } - - gotLabel := endpointSlice.ObjectMeta.Labels[serviceOwnerLabel] - wantLabel := c.ServiceName - return gotLabel == wantLabel - } - - return ctrl.NewControllerManagedBy(mgr). - For(&discoveryv1.EndpointSlice{}, - builder.WithPredicates(predicate.NewPredicateFuncs(ownsEndPointSlice))). - Complete(c) -} - -func (c *EndpointSliceReconciler) validPod(endpoint discoveryv1.Endpoint) bool { - validZone := c.Zone == "" || c.Zone != "" && *endpoint.Zone == c.Zone - return validZone && *endpoint.Conditions.Ready - -} diff --git a/pkg/ext-proc/backend/endpointslice_reconcilier_test.go b/pkg/ext-proc/backend/endpointslice_reconcilier_test.go deleted file mode 100644 index 9a3d55d8..00000000 --- a/pkg/ext-proc/backend/endpointslice_reconcilier_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package backend - -import ( - "sync" - "testing" - - v1 "k8s.io/api/core/v1" - discoveryv1 "k8s.io/api/discovery/v1" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -var ( - basePod1 = Pod{Name: "pod1"} - basePod2 = Pod{Name: "pod2"} - basePod3 = Pod{Name: "pod3"} -) - -func TestUpdateDatastore_EndpointSliceReconciler(t *testing.T) { - tests := []struct { - name string - datastore *K8sDatastore - incomingSlice *discoveryv1.EndpointSlice - wantPods *sync.Map - }{ - { - name: "Add new pod", - datastore: &K8sDatastore{ - pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - }, - }, - }, - incomingSlice: &discoveryv1.EndpointSlice{ - Endpoints: []discoveryv1.Endpoint{ - { - TargetRef: &v1.ObjectReference{ - Name: "pod1", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - { - TargetRef: &v1.ObjectReference{ - Name: "pod2", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - { - TargetRef: &v1.ObjectReference{ - Name: "pod3", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - }, - }, - wantPods: populateMap(basePod1, basePod2, basePod3), - }, - { - name: "New pod, but its not ready yet. Do not add.", - datastore: &K8sDatastore{ - pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - }, - }, - }, - incomingSlice: &discoveryv1.EndpointSlice{ - Endpoints: []discoveryv1.Endpoint{ - { - TargetRef: &v1.ObjectReference{ - Name: "pod1", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - { - TargetRef: &v1.ObjectReference{ - Name: "pod2", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - { - TargetRef: &v1.ObjectReference{ - Name: "pod3", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: new(bool), - }, - Addresses: []string{"0.0.0.0"}, - }, - }, - }, - wantPods: populateMap(basePod1, basePod2), - }, - { - name: "Existing pod not ready, new pod added, and is ready", - datastore: &K8sDatastore{ - pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - }, - }, - }, - incomingSlice: &discoveryv1.EndpointSlice{ - Endpoints: []discoveryv1.Endpoint{ - { - TargetRef: &v1.ObjectReference{ - Name: "pod1", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: new(bool), - }, - Addresses: []string{"0.0.0.0"}, - }, - { - TargetRef: &v1.ObjectReference{ - Name: "pod2", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - { - TargetRef: &v1.ObjectReference{ - Name: "pod3", - }, - Zone: new(string), - Conditions: discoveryv1.EndpointConditions{ - Ready: truePointer(), - }, - Addresses: []string{"0.0.0.0"}, - }, - }, - }, - wantPods: populateMap(basePod3, basePod2), - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - endpointSliceReconciler := &EndpointSliceReconciler{Datastore: test.datastore, Zone: ""} - endpointSliceReconciler.updateDatastore(test.incomingSlice, test.datastore.inferencePool) - - if mapsEqual(endpointSliceReconciler.Datastore.pods, test.wantPods) { - t.Errorf("Unexpected output pod mismatch. \n Got %v \n Want: %v \n", - endpointSliceReconciler.Datastore.pods, - test.wantPods) - } - }) - } -} - -func mapsEqual(map1, map2 *sync.Map) bool { - equal := true - - map1.Range(func(k, v any) bool { - if _, ok := map2.Load(k); !ok { - equal = false - return false - } - return true - }) - map2.Range(func(k, v any) bool { - if _, ok := map1.Load(k); !ok { - equal = false - return false - } - return true - }) - - return equal -} - -func truePointer() *bool { - primitivePointersAreSilly := true - return &primitivePointersAreSilly -} diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index 415358b2..c5ef8d14 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -309,3 +309,24 @@ func populateServiceMap(services ...*v1alpha1.InferenceModel) *sync.Map { } return returnVal } + +func mapsEqual(map1, map2 *sync.Map) bool { + equal := true + + map1.Range(func(k, v any) bool { + if _, ok := map2.Load(k); !ok { + equal = false + return false + } + return true + }) + map2.Range(func(k, v any) bool { + if _, ok := map1.Load(k); !ok { + equal = false + return false + } + return true + }) + + return equal +} diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/backend/inferencepool_reconciler.go index b4cba202..fd15ebc3 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler.go @@ -2,6 +2,7 @@ package backend import ( "context" + "reflect" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -21,7 +22,6 @@ type InferencePoolReconciler struct { Record record.EventRecorder PoolNamespacedName types.NamespacedName Datastore *K8sDatastore - Zone string } func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -32,11 +32,15 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques serverPool := &v1alpha1.InferencePool{} if err := c.Get(ctx, req.NamespacedName, serverPool); err != nil { - klog.Error(err, "unable to get InferencePool") + klog.Error(err, ": unable to get InferencePool") return ctrl.Result{}, err } - - c.updateDatastore(serverPool) + if c.Datastore.inferencePool == nil || !reflect.DeepEqual(serverPool.Spec.Selector, c.Datastore.inferencePool.Spec.Selector) { + c.updateDatastore(serverPool) + c.Datastore.flushPodsAndRefetch(ctx, c.Client, serverPool) + } else { + c.updateDatastore(serverPool) + } return ctrl.Result{}, nil } diff --git a/pkg/ext-proc/backend/pod_reconciler.go b/pkg/ext-proc/backend/pod_reconciler.go new file mode 100644 index 00000000..60d014ce --- /dev/null +++ b/pkg/ext-proc/backend/pod_reconciler.go @@ -0,0 +1,80 @@ +package backend + +import ( + "context" + "strconv" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" +) + +type PodReconciler struct { + client.Client + Datastore *K8sDatastore + Scheme *runtime.Scheme + Record record.EventRecorder +} + +func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + inferencePool, err := c.Datastore.getInferencePool() + if err != nil { + klog.V(logutil.DEFAULT).Infof("Skipping reconciling Pod because the InferencePool is not available yet: %v", err) + // When the inferencePool is initialized it lists the appropriate pods and populates the datastore, so no need to requeue. + return ctrl.Result{}, nil + } else if inferencePool.Namespace != req.Namespace { + return ctrl.Result{}, nil + } + + klog.V(logutil.VERBOSE).Info("reconciling Pod", req.NamespacedName) + + pod := &corev1.Pod{} + if err := c.Get(ctx, req.NamespacedName, pod); err != nil { + klog.Error(err, ": unable to get pod") + if apierrors.IsNotFound(err) { + c.Datastore.pods.Delete(pod) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + c.updateDatastore(pod, inferencePool) + + return ctrl.Result{}, nil +} + +func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Pod{}). + Complete(c) +} + +func (c *PodReconciler) updateDatastore(k8sPod *corev1.Pod, inferencePool *v1alpha1.InferencePool) { + pod := Pod{ + Name: k8sPod.Name, + Address: k8sPod.Status.PodIP + ":" + strconv.Itoa(int(inferencePool.Spec.TargetPortNumber)), + } + if !k8sPod.DeletionTimestamp.IsZero() || !c.Datastore.LabelsMatch(k8sPod.ObjectMeta.Labels) || !podIsReady(k8sPod) { + c.Datastore.pods.Delete(pod) + } else { + c.Datastore.pods.Store(pod, true) + } +} + +func podIsReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + if condition.Status == corev1.ConditionTrue { + return true + } + break + } + } + return false +} diff --git a/pkg/ext-proc/backend/pod_reconciler_test.go b/pkg/ext-proc/backend/pod_reconciler_test.go new file mode 100644 index 00000000..42d6d8e4 --- /dev/null +++ b/pkg/ext-proc/backend/pod_reconciler_test.go @@ -0,0 +1,168 @@ +package backend + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" +) + +var ( + basePod1 = Pod{Name: "pod1", Address: ":8000"} + basePod2 = Pod{Name: "pod2", Address: ":8000"} + basePod3 = Pod{Name: "pod3", Address: ":8000"} +) + +func TestUpdateDatastore_PodReconciler(t *testing.T) { + tests := []struct { + name string + datastore *K8sDatastore + incomingPod *corev1.Pod + wantPods []string + }{ + { + name: "Add new pod", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + incomingPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod3", + Labels: map[string]string{ + "some-key": "some-val", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + wantPods: []string{basePod1.Name, basePod2.Name, basePod3.Name}, + }, + { + name: "New pod, not ready, valid selector", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + incomingPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod3", + Labels: map[string]string{ + "some-key": "some-val", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + wantPods: []string{basePod1.Name, basePod2.Name}, + }, + { + name: "Remove pod that does not match selector", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + incomingPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Labels: map[string]string{ + "some-wrong-key": "some-val", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + wantPods: []string{basePod2.Name}, + }, + { + name: "Remove pod that is not ready", + datastore: &K8sDatastore{ + pods: populateMap(basePod1, basePod2), + inferencePool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + incomingPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Labels: map[string]string{ + "some-wrong-key": "some-val", + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + wantPods: []string{basePod2.Name}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + podReconciler := &PodReconciler{Datastore: test.datastore} + podReconciler.updateDatastore(test.incomingPod, test.datastore.inferencePool) + var gotPods []string + test.datastore.pods.Range(func(k, v any) bool { + pod := k.(Pod) + if v != nil { + gotPods = append(gotPods, pod.Name) + } + return true + }) + if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b string) bool { return a < b })) { + t.Errorf("got (%v) != want (%v);", gotPods, test.wantPods) + } + }) + } +} diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index d51435ac..30b87299 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -59,14 +59,6 @@ var ( "poolNamespace", runserver.DefaultPoolNamespace, "Namespace of the InferencePool this Endpoint Picker is associated with.") - serviceName = flag.String( - "serviceName", - runserver.DefaultServiceName, - "Name of the Service that will be used to read EndpointSlices from") - zone = flag.String( - "zone", - runserver.DefaultZone, - "The zone that this instance is created in. Will be passed to the corresponding endpointSlice. ") refreshPodsInterval = flag.Duration( "refreshPodsInterval", runserver.DefaultRefreshPodsInterval, @@ -128,8 +120,6 @@ func run() error { TargetEndpointKey: *targetEndpointKey, PoolName: *poolName, PoolNamespace: *poolNamespace, - ServiceName: *serviceName, - Zone: *zone, RefreshPodsInterval: *refreshPodsInterval, RefreshMetricsInterval: *refreshMetricsInterval, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, @@ -252,9 +242,5 @@ func validateFlags() error { return fmt.Errorf("required %q flag not set", "poolName") } - if *serviceName == "" { - return fmt.Errorf("required %q flag not set", "serviceName") - } - return nil } diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index 71499e8f..d7d4c71a 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -26,8 +26,6 @@ type ExtProcServerRunner struct { TargetEndpointKey string PoolName string PoolNamespace string - ServiceName string - Zone string RefreshPodsInterval time.Duration RefreshMetricsInterval time.Duration RefreshPrometheusMetricsInterval time.Duration @@ -43,8 +41,6 @@ const ( DefaultTargetEndpointKey = "x-gateway-destination-endpoint" // default for --targetEndpointKey DefaultPoolName = "" // required but no default DefaultPoolNamespace = "default" // default for --poolNamespace - DefaultServiceName = "" // required but no default - DefaultZone = "" // default for --zone DefaultRefreshPodsInterval = 10 * time.Second // default for --refreshPodsInterval DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval DefaultRefreshPrometheusMetricsInterval = 5 * time.Second // default for --refreshPrometheusMetricsInterval @@ -56,8 +52,6 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { TargetEndpointKey: DefaultTargetEndpointKey, PoolName: DefaultPoolName, PoolNamespace: DefaultPoolNamespace, - ServiceName: DefaultServiceName, - Zone: DefaultZone, RefreshPodsInterval: DefaultRefreshPodsInterval, RefreshMetricsInterval: DefaultRefreshMetricsInterval, RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, @@ -101,13 +95,11 @@ func (r *ExtProcServerRunner) Setup() error { return fmt.Errorf("failed setting up InferenceModelReconciler: %w", err) } - if err := (&backend.EndpointSliceReconciler{ - Datastore: r.Datastore, - Scheme: mgr.GetScheme(), - Client: mgr.GetClient(), - Record: mgr.GetEventRecorderFor("endpointslice"), - ServiceName: r.ServiceName, - Zone: r.Zone, + if err := (&backend.PodReconciler{ + Datastore: r.Datastore, + Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Record: mgr.GetEventRecorderFor("pod"), }).SetupWithManager(mgr); err != nil { return fmt.Errorf("failed setting up EndpointSliceReconciler: %v", err) } diff --git a/pkg/manifests/ext_proc.yaml b/pkg/manifests/ext_proc.yaml index a7dc7678..49145d24 100644 --- a/pkg/manifests/ext_proc.yaml +++ b/pkg/manifests/ext_proc.yaml @@ -78,8 +78,6 @@ spec: - "vllm-llama2-7b-pool" - -v - "3" - - -serviceName - - "vllm-llama2-7b-pool" - -grpcPort - "9002" - -grpcHealthPort From 5bc8fcdd64de5a9125e028c1763596c49d4084bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Thu, 13 Feb 2025 20:20:21 +0100 Subject: [PATCH 019/260] Move manager from runserver to main (#331) The manager setup logic is now moved to main. runserver package does not manage the manager any more. This establishes a clear separation of concerns. --- pkg/ext-proc/main.go | 34 +++++++++++++++++++---------- pkg/ext-proc/server/runserver.go | 36 +++---------------------------- test/integration/hermetic_test.go | 13 +++++++---- 3 files changed, 35 insertions(+), 48 deletions(-) diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 30b87299..968d09f5 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -95,11 +95,6 @@ func run() error { flag.Parse() initLogging(&opts) - cfg, err := ctrl.GetConfig() - if err != nil { - klog.ErrorS(err, "Failed to get rest config") - return err - } // Validate flags if err := validateFlags(); err != nil { klog.ErrorS(err, "Failed to validate flags") @@ -115,6 +110,20 @@ func run() error { datastore := backend.NewK8sDataStore() + // Init runtime. + cfg, err := ctrl.GetConfig() + if err != nil { + klog.ErrorS(err, "Failed to get rest config") + return err + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) + if err != nil { + klog.ErrorS(err, "Failed to create controller manager", "config", cfg) + return err + } + + // Setup runner. serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, TargetEndpointKey: *targetEndpointKey, @@ -123,15 +132,12 @@ func run() error { RefreshPodsInterval: *refreshPodsInterval, RefreshMetricsInterval: *refreshMetricsInterval, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, - Scheme: scheme, - Config: ctrl.GetConfigOrDie(), Datastore: datastore, } - if err := serverRunner.Setup(); err != nil { + if err := serverRunner.SetupWithManager(mgr); err != nil { klog.ErrorS(err, "Failed to setup ext-proc server") return err } - mgr := serverRunner.Manager // Register health server. if err := registerHealthServer(mgr, datastore, *grpcHealthPort); err != nil { @@ -149,8 +155,14 @@ func run() error { return err } - // Start the manager. - return serverRunner.StartManager(ctrl.SetupSignalHandler()) + // Start the manager. This blocks until a signal is received. + klog.InfoS("Controller manager starting") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + klog.ErrorS(err, "Error starting controller manager") + return err + } + klog.InfoS("Controller manager terminated") + return nil } func initLogging(opts *zap.Options) { diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index d7d4c71a..2d92e412 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -2,15 +2,12 @@ package server import ( "context" - "errors" "fmt" "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/grpc" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/rest" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -29,10 +26,7 @@ type ExtProcServerRunner struct { RefreshPodsInterval time.Duration RefreshMetricsInterval time.Duration RefreshPrometheusMetricsInterval time.Duration - Scheme *runtime.Scheme - Config *rest.Config Datastore *backend.K8sDatastore - Manager ctrl.Manager } // Default values for CLI flags in main @@ -55,19 +49,12 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { RefreshPodsInterval: DefaultRefreshPodsInterval, RefreshMetricsInterval: DefaultRefreshMetricsInterval, RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, - // Scheme, Config, and Datastore can be assigned later. + // Datastore can be assigned later. } } -// Setup creates the reconcilers for pools, models, and endpointSlices and starts the manager. -func (r *ExtProcServerRunner) Setup() error { - // Create a new manager to manage controllers - mgr, err := ctrl.NewManager(r.Config, ctrl.Options{Scheme: r.Scheme}) - if err != nil { - return fmt.Errorf("failed to create controller manager: %w", err) - } - r.Manager = mgr - +// SetupWithManager sets up the runner with the given manager. +func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { // Create the controllers and register them with the manager if err := (&backend.InferencePoolReconciler{ Datastore: r.Datastore, @@ -131,20 +118,3 @@ func (r *ExtProcServerRunner) AsRunnable( return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) })) } - -func (r *ExtProcServerRunner) StartManager(ctx context.Context) error { - if r.Manager == nil { - err := errors.New("runner manager is not set") - klog.ErrorS(err, "Runner has no manager setup to run") - return err - } - - // Start the controller manager. Blocking and will return when shutdown is complete. - klog.InfoS("Controller manager starting") - if err := r.Manager.Start(ctx); err != nil { - klog.ErrorS(err, "Error starting controller manager") - return err - } - klog.InfoS("Controller manager terminated") - return nil -} diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 74c9f049..ff018f28 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -468,20 +468,25 @@ func BeforeSuit() { log.Fatalf("No error, but returned kubernetes client is nil, cfg: %v", cfg) } + // Init runtime. + mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) + if err != nil { + klog.ErrorS(err, "Failed to create controller manager") + klog.FlushAndExit(klog.ExitFlushTimeout, 1) + } + serverRunner = runserver.NewDefaultExtProcServerRunner() // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" - serverRunner.Scheme = scheme - serverRunner.Config = cfg serverRunner.Datastore = backend.NewK8sDataStore() - if err := serverRunner.Setup(); err != nil { + if err := serverRunner.SetupWithManager(mgr); err != nil { log.Fatalf("Failed to start server runner: %v", err) } // Start the controller manager in go routine, not blocking go func() { - if err := serverRunner.StartManager(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { log.Fatalf("Failed to start manager: %v", err) } }() From cdf3533105b34f92783c13c1b8a7dced580b15f2 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Thu, 13 Feb 2025 18:20:20 -0500 Subject: [PATCH 020/260] Adds image-load and kind-load Make targets (#288) Signed-off-by: Daneyon Hansen --- Makefile | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 83de8dd1..b7654ed7 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,9 @@ ifdef IMAGE_EXTRA_TAG IMAGE_BUILD_EXTRA_OPTS += -t $(IMAGE_EXTRA_TAG) endif +# The name of the kind cluster to use for the "kind-load" target. +KIND_CLUSTER ?= kind + ##@ General # The help target prints out all targets with their descriptions organized @@ -132,28 +135,42 @@ verify: vet fmt-verify manifests generate ci-lint # Build the container image .PHONY: image-local-build -image-local-build: +image-local-build: ## Build the EPP image using Docker Buildx for local development. BUILDER=$(shell $(DOCKER_BUILDX_CMD) create --use) $(MAKE) image-build PUSH=$(PUSH) + $(MAKE) image-build LOAD=$(LOAD) $(DOCKER_BUILDX_CMD) rm $$BUILDER .PHONY: image-local-push -image-local-push: PUSH=--push +image-local-push: PUSH=--push ## Build the EPP image for local development and push it to $IMAGE_REPO. image-local-push: image-local-build +.PHONY: image-local-load +image-local-load: LOAD=--load ## Build the EPP image for local development and load it in the local Docker registry. +image-local-load: image-local-build + .PHONY: image-build -image-build: +image-build: ## Build the EPP image using Docker Buildx. $(IMAGE_BUILD_CMD) -t $(IMAGE_TAG) \ --platform=$(PLATFORMS) \ --build-arg BASE_IMAGE=$(BASE_IMAGE) \ --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ $(PUSH) \ + $(LOAD) \ $(IMAGE_BUILD_EXTRA_OPTS) ./ .PHONY: image-push -image-push: PUSH=--push +image-push: PUSH=--push ## Build the EPP image and push it to $IMAGE_REPO. image-push: image-build +.PHONY: image-load +image-load: LOAD=--load ## Build the EPP image and load it in the local Docker registry. +image-load: image-build + +.PHONY: image-kind +image-kind: image-build ## Build the EPP image and load it to kind cluster $KIND_CLUSTER ("kind" by default). + kind load docker-image $(IMAGE_TAG) --name $(KIND_CLUSTER) + ##@ Docs .PHONY: build-docs From ef9b92fbf1fb44c85232ab701aeb6cab42ef4ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Fri, 14 Feb 2025 00:54:20 +0100 Subject: [PATCH 021/260] Use structured logging (#330) * Use structured logging All logging calls are rewritten to use structured logging. * test/integration: Use logutil.Fatal --- pkg/ext-proc/backend/datastore.go | 2 +- pkg/ext-proc/backend/fake.go | 3 +- .../backend/inferencemodel_reconciler.go | 18 +++++---- .../backend/inferencepool_reconciler.go | 8 ++-- pkg/ext-proc/backend/provider.go | 19 +++++----- pkg/ext-proc/backend/vllm/metrics.go | 14 +++---- pkg/ext-proc/handlers/request.go | 22 +++++------ pkg/ext-proc/handlers/response.go | 8 ++-- pkg/ext-proc/handlers/server.go | 20 +++++----- pkg/ext-proc/health.go | 5 ++- pkg/ext-proc/main.go | 6 +-- pkg/ext-proc/metrics/metrics.go | 12 ++++-- pkg/ext-proc/scheduling/filter.go | 6 +-- pkg/ext-proc/scheduling/scheduler.go | 7 ++-- pkg/ext-proc/test/benchmark/benchmark.go | 15 ++++++-- pkg/ext-proc/test/utils.go | 11 +++--- pkg/ext-proc/util/logging/fatal.go | 11 ++++++ test/integration/hermetic_test.go | 37 +++++++++---------- 18 files changed, 128 insertions(+), 96 deletions(-) create mode 100644 pkg/ext-proc/util/logging/fatal.go diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/backend/datastore.go index be3c7f0b..a54833bc 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/backend/datastore.go @@ -98,7 +98,7 @@ func RandomWeightedDraw(model *v1alpha1.InferenceModel, seed int64) string { for _, model := range model.Spec.TargetModels { weights += *model.Weight } - klog.V(logutil.VERBOSE).Infof("Weights for Model(%v) total to: %v", model.Name, weights) + klog.V(logutil.VERBOSE).InfoS("Weights for model computed", "model", model.Name, "weights", weights) randomVal := r.Int31n(weights) for _, model := range model.Spec.TargetModels { if randomVal < *model.Weight { diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index 8c028b77..7ab8a464 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -5,6 +5,7 @@ import ( klog "k8s.io/klog/v2" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type FakePodMetricsClient struct { @@ -16,7 +17,7 @@ func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod Pod, existi if err, ok := f.Err[pod]; ok { return nil, err } - klog.V(1).Infof("pod: %+v\n existing: %+v \n new: %+v \n", pod, existing, f.Res[pod]) + klog.V(logutil.VERBOSE).InfoS("Fetching metrics for pod", "pod", pod, "existing", existing, "new", f.Res[pod]) return f.Res[pod], nil } diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/backend/inferencemodel_reconciler.go index f0a13941..72ea063e 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler.go @@ -26,19 +26,21 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque if req.Namespace != c.PoolNamespacedName.Namespace { return ctrl.Result{}, nil } - klog.V(1).Infof("Reconciling InferenceModel %v", req.NamespacedName) + + klogV := klog.V(logutil.DEFAULT) + klogV.InfoS("Reconciling InferenceModel", "name", req.NamespacedName) infModel := &v1alpha1.InferenceModel{} if err := c.Get(ctx, req.NamespacedName, infModel); err != nil { if errors.IsNotFound(err) { - klog.V(1).Infof("InferenceModel %v not found. Removing from datastore since object must be deleted", req.NamespacedName) + klogV.InfoS("InferenceModel not found. Removing from datastore since object must be deleted", "name", req.NamespacedName) c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) return ctrl.Result{}, nil } - klog.Error(err, "Unable to get InferenceModel") + klogV.ErrorS(err, "Unable to get InferenceModel", "name", req.NamespacedName) return ctrl.Result{}, err } else if !infModel.DeletionTimestamp.IsZero() { - klog.V(1).Infof("InferenceModel %v is marked for deletion. Removing from datastore", req.NamespacedName) + klogV.InfoS("InferenceModel is marked for deletion. Removing from datastore", "name", req.NamespacedName) c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) return ctrl.Result{}, nil } @@ -48,13 +50,15 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque } func (c *InferenceModelReconciler) updateDatastore(infModel *v1alpha1.InferenceModel) { + klogV := klog.V(logutil.DEFAULT) + if infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name { - klog.V(1).Infof("Incoming pool ref %v, server pool name: %v", infModel.Spec.PoolRef, c.PoolNamespacedName.Name) - klog.V(1).Infof("Adding/Updating InferenceModel: %v", infModel.Spec.ModelName) + klogV.InfoS("Updating datastore", "poolRef", infModel.Spec.PoolRef, "serverPoolName", c.PoolNamespacedName) + klogV.InfoS("Adding/Updating InferenceModel", "modelName", infModel.Spec.ModelName) c.Datastore.InferenceModels.Store(infModel.Spec.ModelName, infModel) return } - klog.V(logutil.DEFAULT).Infof("Removing/Not adding InferenceModel: %v", infModel.Spec.ModelName) + klogV.InfoS("Removing/Not adding InferenceModel", "modelName", infModel.Spec.ModelName) // If we get here. The model is not relevant to this pool, remove. c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) } diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/backend/inferencepool_reconciler.go index fd15ebc3..9504b4e0 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler.go @@ -11,6 +11,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) // InferencePoolReconciler utilizes the controller runtime to reconcile Instance Gateway resources @@ -28,11 +29,12 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques if req.NamespacedName.Name != c.PoolNamespacedName.Name || req.NamespacedName.Namespace != c.PoolNamespacedName.Namespace { return ctrl.Result{}, nil } - klog.V(1).Info("reconciling InferencePool", req.NamespacedName) + klogV := klog.V(logutil.DEFAULT) + klogV.InfoS("Reconciling InferencePool", "name", req.NamespacedName) serverPool := &v1alpha1.InferencePool{} if err := c.Get(ctx, req.NamespacedName, serverPool); err != nil { - klog.Error(err, ": unable to get InferencePool") + klogV.ErrorS(err, "Unable to get InferencePool", "name", req.NamespacedName) return ctrl.Result{}, err } if c.Datastore.inferencePool == nil || !reflect.DeepEqual(serverPool.Spec.Selector, c.Datastore.inferencePool.Spec.Selector) { @@ -49,7 +51,7 @@ func (c *InferencePoolReconciler) updateDatastore(serverPool *v1alpha1.Inference pool, _ := c.Datastore.getInferencePool() if pool == nil || serverPool.ObjectMeta.ResourceVersion != pool.ObjectMeta.ResourceVersion { - klog.Infof("Updating inference pool to %v/%v", serverPool.ObjectMeta.Namespace, serverPool.ObjectMeta.Name) + klog.V(logutil.DEFAULT).InfoS("Updating inference pool", "target", klog.KMetadata(&serverPool.ObjectMeta)) c.Datastore.setInferencePool(serverPool) } } diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index 68043d93..d64b80b3 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -63,10 +63,10 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshProm p.refreshPodsOnce() if err := p.refreshMetricsOnce(); err != nil { - klog.Errorf("Failed to init metrics: %v", err) + klog.ErrorS(err, "Failed to init metrics") } - klog.Infof("Initialized pods and metrics: %+v", p.AllPodMetrics()) + klog.InfoS("Initialized pods and metrics", "metrics", p.AllPodMetrics()) // periodically refresh pods go func() { @@ -81,7 +81,7 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshProm for { time.Sleep(refreshMetricsInterval) if err := p.refreshMetricsOnce(); err != nil { - klog.V(logutil.TRACE).Infof("Failed to refresh metrics: %v", err) + klog.V(logutil.TRACE).ErrorS(err, "Failed to refresh metrics") } } }() @@ -95,11 +95,11 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshProm }() // Periodically print out the pods and metrics for DEBUGGING. - if klog.V(logutil.DEBUG).Enabled() { + if klogV := klog.V(logutil.DEBUG); klogV.Enabled() { go func() { for { time.Sleep(5 * time.Second) - klog.Infof("===DEBUG: Current Pods and metrics: %+v", p.AllPodMetrics()) + klogV.InfoS("Current Pods and metrics gathered", "metrics", p.AllPodMetrics()) } }() } @@ -138,18 +138,19 @@ func (p *Provider) refreshPodsOnce() { } func (p *Provider) refreshMetricsOnce() error { + klogV := klog.V(logutil.TRACE) ctx, cancel := context.WithTimeout(context.Background(), fetchMetricsTimeout) defer cancel() start := time.Now() defer func() { d := time.Since(start) // TODO: add a metric instead of logging - klog.V(logutil.TRACE).Infof("Refreshed metrics in %v", d) + klogV.InfoS("Metrics refreshed", "duration", d) }() var wg sync.WaitGroup errCh := make(chan error) processOnePod := func(key, value any) bool { - klog.V(logutil.TRACE).Infof("Processing pod %v and metric %v", key, value) + klogV.InfoS("Pod and metric being processed", "pod", key, "metric", value) pod := key.(Pod) existing := value.(*PodMetrics) wg.Add(1) @@ -161,7 +162,7 @@ func (p *Provider) refreshMetricsOnce() error { return } p.UpdatePodMetrics(pod, updated) - klog.V(logutil.TRACE).Infof("Updated metrics for pod %s: %v", pod, updated.Metrics) + klogV.InfoS("Updated metrics for pod", "pod", pod, "metrics", updated.Metrics) }() return true } @@ -185,7 +186,7 @@ func (p *Provider) refreshMetricsOnce() error { } func (p *Provider) flushPrometheusMetricsOnce() { - klog.V(logutil.DEBUG).Infof("Flushing Prometheus Metrics") + klog.V(logutil.DEBUG).InfoS("Flushing Prometheus Metrics") pool, _ := p.datastore.getInferencePool() if pool == nil { diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index e3693960..4c3804ce 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -32,8 +32,7 @@ const ( KvCacheMaxTokenCapacityMetricName = "vllm:gpu_cache_max_token_capacity" ) -type PodMetricsClientImpl struct { -} +type PodMetricsClientImpl struct{} // FetchMetrics fetches metrics from a given pod. func (p *PodMetricsClientImpl) FetchMetrics( @@ -46,11 +45,12 @@ func (p *PodMetricsClientImpl) FetchMetrics( url := fmt.Sprintf("http://%s/metrics", pod.Address) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { + klog.V(logutil.DEFAULT).ErrorS(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) return nil, fmt.Errorf("failed to create request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { - klog.Errorf("failed to fetch metrics from %s: %v", pod, err) + klog.V(logutil.DEFAULT).ErrorS(err, "Failed to fetch metrics", "pod", pod) return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod, err) } defer func() { @@ -58,7 +58,7 @@ func (p *PodMetricsClientImpl) FetchMetrics( }() if resp.StatusCode != http.StatusOK { - klog.Errorf("unexpected status code from %s: %v", pod, resp.StatusCode) + klog.V(logutil.DEFAULT).ErrorS(nil, "Unexpected status code returned", "pod", pod, "statusCode", resp.StatusCode) return nil, fmt.Errorf("unexpected status code from %s: %v", pod, resp.StatusCode) } @@ -138,7 +138,7 @@ func promToPodMetrics( func getLatestLoraMetric(metricFamilies map[string]*dto.MetricFamily) (*dto.Metric, time.Time, error) { loraRequests, ok := metricFamilies[LoraRequestInfoMetricName] if !ok { - klog.Warningf("metric family %q not found", LoraRequestInfoMetricName) + klog.V(logutil.DEFAULT).ErrorS(nil, "Metric family not found", "name", LoraRequestInfoMetricName) return nil, time.Time{}, fmt.Errorf("metric family %q not found", LoraRequestInfoMetricName) } var latestTs float64 @@ -157,7 +157,7 @@ func getLatestLoraMetric(metricFamilies map[string]*dto.MetricFamily) (*dto.Metr func getLatestMetric(metricFamilies map[string]*dto.MetricFamily, metricName string) (*dto.Metric, error) { mf, ok := metricFamilies[metricName] if !ok { - klog.Warningf("metric family %q not found", metricName) + klog.V(logutil.DEFAULT).ErrorS(nil, "Metric family not found", "name", metricName) return nil, fmt.Errorf("metric family %q not found", metricName) } if len(mf.GetMetric()) == 0 { @@ -171,6 +171,6 @@ func getLatestMetric(metricFamilies map[string]*dto.MetricFamily, metricName str latest = m } } - klog.V(logutil.TRACE).Infof("Got metric value %+v for metric %v", latest, metricName) + klog.V(logutil.TRACE).InfoS("Metric value selected", "value", latest, "metric", metricName) return latest, nil } diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index 17278025..a36f7ae3 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -19,23 +19,24 @@ import ( // parameter. // Envoy sends the request body to ext proc before sending the request to the backend server. func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - klog.V(logutil.VERBOSE).Infof("Handling request body") + klogV := klog.V(logutil.VERBOSE) + klogV.InfoS("Handling request body") // Unmarshal request body (must be JSON). v := req.Request.(*extProcPb.ProcessingRequest_RequestBody) var rb map[string]interface{} if err := json.Unmarshal(v.RequestBody.Body, &rb); err != nil { - klog.Errorf("Error unmarshaling request body: %v", err) + klog.V(logutil.DEFAULT).ErrorS(err, "Error unmarshaling request body") return nil, fmt.Errorf("error unmarshaling request body: %v", err) } - klog.V(logutil.VERBOSE).Infof("Request body: %v", rb) + klogV.InfoS("Request body unmarshalled", "body", rb) // Resolve target models. model, ok := rb["model"].(string) if !ok { return nil, errors.New("model not found in request") } - klog.V(logutil.VERBOSE).Infof("Model requested: %v", model) + klogV.InfoS("Model requested", "model", model) modelName := model // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. @@ -56,7 +57,7 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces ResolvedTargetModel: modelName, Critical: backend.IsCritical(modelObj), } - klog.V(logutil.VERBOSE).Infof("LLM Request: %+v", llmReq) + klogV.InfoS("LLM request assembled", "request", llmReq) requestBody := v.RequestBody.Body var err error @@ -65,17 +66,17 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces rb["model"] = llmReq.ResolvedTargetModel requestBody, err = json.Marshal(rb) if err != nil { - klog.Errorf("Error marshaling request body: %v", err) + klog.V(logutil.DEFAULT).ErrorS(err, "Error marshaling request body") return nil, fmt.Errorf("error marshaling request body: %v", err) } - klog.V(logutil.VERBOSE).Infof("Updated body: %v", string(requestBody)) + klogV.InfoS("Updated request body marshalled", "body", string(requestBody)) } targetPod, err := s.scheduler.Schedule(llmReq) if err != nil { return nil, fmt.Errorf("failed to find target pod: %w", err) } - klog.V(logutil.VERBOSE).Infof("Selected target model %v in target pod: %v\n", llmReq.ResolvedTargetModel, targetPod) + klogV.InfoS("Target model and pod selected", "model", llmReq.ResolvedTargetModel, "pod", targetPod) reqCtx.Model = llmReq.Model reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel @@ -101,7 +102,7 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces } // Print headers for debugging for _, header := range headers { - klog.V(logutil.VERBOSE).Infof("[request_body] Header Key: %s, Header Value: %s\n", header.Header.Key, header.Header.RawValue) + klog.V(logutil.DEBUG).InfoS("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) } resp := &extProcPb.ProcessingResponse{ @@ -136,10 +137,9 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces } func HandleRequestHeaders(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) *extProcPb.ProcessingResponse { - klog.V(logutil.VERBOSE).Info("Handling request headers ...") r := req.Request h := r.(*extProcPb.ProcessingRequest_RequestHeaders) - klog.V(logutil.VERBOSE).Infof("Headers: %+v\n", h) + klog.V(logutil.VERBOSE).InfoS("Handling request headers", "headers", h) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestHeaders{ diff --git a/pkg/ext-proc/handlers/response.go b/pkg/ext-proc/handlers/response.go index 34a7219a..012b0b8d 100644 --- a/pkg/ext-proc/handlers/response.go +++ b/pkg/ext-proc/handlers/response.go @@ -12,9 +12,9 @@ import ( // HandleResponseHeaders processes response headers from the backend model server. func (s *Server) HandleResponseHeaders(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - klog.V(logutil.VERBOSE).Info("Processing ResponseHeaders") + klog.V(logutil.VERBOSE).InfoS("Processing ResponseHeaders") h := req.Request.(*extProcPb.ProcessingRequest_ResponseHeaders) - klog.V(logutil.VERBOSE).Infof("Headers before: %+v\n", h) + klog.V(logutil.VERBOSE).InfoS("Headers before", "headers", h) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseHeaders{ @@ -66,7 +66,7 @@ func (s *Server) HandleResponseHeaders(reqCtx *RequestContext, req *extProcPb.Pr } }*/ func (s *Server) HandleResponseBody(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - klog.V(logutil.VERBOSE).Info("Processing HandleResponseBody") + klog.V(logutil.VERBOSE).InfoS("Processing HandleResponseBody") body := req.Request.(*extProcPb.ProcessingRequest_ResponseBody) res := Response{} @@ -81,7 +81,7 @@ func (s *Server) HandleResponseBody(reqCtx *RequestContext, req *extProcPb.Proce // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) // will add the processing for streaming case. reqCtx.ResponseComplete = true - klog.V(logutil.VERBOSE).Infof("Response: %+v", res) + klog.V(logutil.VERBOSE).InfoS("Response generated", "response", res) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseBody{ diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index f27c9a15..a3cfcada 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -51,7 +51,7 @@ type ModelDataStore interface { } func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { - klog.V(logutil.VERBOSE).Info("Processing") + klog.V(logutil.VERBOSE).InfoS("Processing") ctx := srv.Context() // Create request context to share states during life time of an HTTP request. // See https://github.com/envoyproxy/envoy/issues/17540. @@ -71,7 +71,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { if err != nil { // This error occurs very frequently, though it doesn't seem to have any impact. // TODO Figure out if we can remove this noise. - klog.V(logutil.VERBOSE).Infof("cannot receive stream request: %v", err) + klog.V(logutil.VERBOSE).ErrorS(err, "Cannot receive stream request") return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) } @@ -80,17 +80,17 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { case *extProcPb.ProcessingRequest_RequestHeaders: reqCtx.RequestReceivedTimestamp = time.Now() resp = HandleRequestHeaders(reqCtx, req) - klog.V(logutil.VERBOSE).Infof("Request context after HandleRequestHeaders: %+v", reqCtx) + klog.V(logutil.VERBOSE).InfoS("Request context after HandleRequestHeaders", "context", reqCtx) case *extProcPb.ProcessingRequest_RequestBody: resp, err = s.HandleRequestBody(reqCtx, req) if err == nil { metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) } - klog.V(logutil.VERBOSE).Infof("Request context after HandleRequestBody: %+v", reqCtx) + klog.V(logutil.VERBOSE).InfoS("Request context after HandleRequestBody", "context", reqCtx) case *extProcPb.ProcessingRequest_ResponseHeaders: resp, err = s.HandleResponseHeaders(reqCtx, req) - klog.V(logutil.VERBOSE).Infof("Request context after HandleResponseHeaders: %+v", reqCtx) + klog.V(logutil.VERBOSE).InfoS("Request context after HandleResponseHeaders", "context", reqCtx) case *extProcPb.ProcessingRequest_ResponseBody: resp, err = s.HandleResponseBody(reqCtx, req) if err == nil && reqCtx.ResponseComplete { @@ -100,13 +100,13 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.CompletionTokens) } - klog.V(logutil.VERBOSE).Infof("Request context after HandleResponseBody: %+v", reqCtx) + klog.V(logutil.VERBOSE).InfoS("Request context after HandleResponseBody", "context", reqCtx) default: - klog.Errorf("Unknown Request type %+v", v) + klog.V(logutil.DEFAULT).ErrorS(nil, "Unknown Request type", "request", v) return status.Error(codes.Unknown, "unknown request type") } if err != nil { - klog.Errorf("failed to process request: %v", err) + klog.V(logutil.DEFAULT).ErrorS(err, "Failed to process request", "request", req) switch status.Code(err) { // This code can be returned by scheduler when there is no capacity for sheddable // requests. @@ -125,9 +125,9 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } - klog.V(logutil.VERBOSE).Infof("response: %v", resp) + klog.V(logutil.VERBOSE).InfoS("Response generated", "response", resp) if err := srv.Send(resp); err != nil { - klog.Errorf("send error %v", err) + klog.V(logutil.DEFAULT).ErrorS(err, "Send failed") return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } diff --git a/pkg/ext-proc/health.go b/pkg/ext-proc/health.go index 764992b2..aabb150d 100644 --- a/pkg/ext-proc/health.go +++ b/pkg/ext-proc/health.go @@ -8,6 +8,7 @@ import ( "google.golang.org/grpc/status" klog "k8s.io/klog/v2" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type healthServer struct { @@ -16,10 +17,10 @@ type healthServer struct { func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) { if !s.datastore.HasSynced() { - klog.Infof("gRPC health check not serving: %s", in.String()) + klog.V(logutil.VERBOSE).InfoS("gRPC health check not serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil } - klog.Infof("gRPC health check serving: %s", in.String()) + klog.V(logutil.VERBOSE).InfoS("gRPC health check serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil } diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 968d09f5..06c77af3 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -102,11 +102,11 @@ func run() error { } // Print all flag values - flags := "Flags: " + flags := make(map[string]any) flag.VisitAll(func(f *flag.Flag) { - flags += fmt.Sprintf("%s=%v; ", f.Name, f.Value) + flags[f.Name] = f.Value }) - klog.Info(flags) + klog.InfoS("Flags processed", "flags", flags) datastore := backend.NewK8sDataStore() diff --git a/pkg/ext-proc/metrics/metrics.go b/pkg/ext-proc/metrics/metrics.go index 7bdc8436..1412af6e 100644 --- a/pkg/ext-proc/metrics/metrics.go +++ b/pkg/ext-proc/metrics/metrics.go @@ -7,6 +7,7 @@ import ( compbasemetrics "k8s.io/component-base/metrics" "k8s.io/component-base/metrics/legacyregistry" klog "k8s.io/klog/v2" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( @@ -31,8 +32,10 @@ var ( Subsystem: InferenceModelComponent, Name: "request_duration_seconds", Help: "Inference model response latency distribution in seconds for each model and target model.", - Buckets: []float64{0.005, 0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.25, 1.5, 2, 3, - 4, 5, 6, 8, 10, 15, 20, 30, 45, 60, 120, 180, 240, 300, 360, 480, 600, 900, 1200, 1800, 2700, 3600}, + Buckets: []float64{ + 0.005, 0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.25, 1.5, 2, 3, + 4, 5, 6, 8, 10, 15, 20, 30, 45, 60, 120, 180, 240, 300, 360, 480, 600, 900, 1200, 1800, 2700, 3600, + }, StabilityLevel: compbasemetrics.ALPHA, }, []string{"model_name", "target_model_name"}, @@ -140,10 +143,11 @@ func RecordRequestSizes(modelName, targetModelName string, reqSize int) { requestSizes.WithLabelValues(modelName, targetModelName).Observe(float64(reqSize)) } -// RecordRequstLatencies records duration of request. +// RecordRequestLatencies records duration of request. func RecordRequestLatencies(modelName, targetModelName string, received time.Time, complete time.Time) bool { if !complete.After(received) { - klog.Errorf("request latency value error for model name %v, target model name %v: complete time %v is before received time %v", modelName, targetModelName, complete, received) + klog.V(logutil.DEFAULT).ErrorS(nil, "Request latency values are invalid", + "modelName", modelName, "targetModelName", targetModelName, "completeTime", complete, "receivedTime", received) return false } elapsedSeconds := complete.Sub(received).Seconds() diff --git a/pkg/ext-proc/scheduling/filter.go b/pkg/ext-proc/scheduling/filter.go index fc016882..ac7a287c 100644 --- a/pkg/ext-proc/scheduling/filter.go +++ b/pkg/ext-proc/scheduling/filter.go @@ -42,7 +42,7 @@ func (f *filter) Name() string { } func (f *filter) Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { - klog.V(logutil.VERBOSE).Infof("Running filter %q on request %v with %v pods", f.name, req, len(pods)) + klog.V(logutil.VERBOSE).InfoS("Running a filter", "name", f.Name(), "request", req, "podCount", len(pods)) filtered, err := f.filter(req, pods) @@ -55,7 +55,7 @@ func (f *filter) Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend if f.nextOnSuccess != nil { next = f.nextOnSuccess } - klog.V(logutil.VERBOSE).Infof("onSuccess %q -> %q, filtered: %v", f.name, next.Name(), len(filtered)) + klog.V(logutil.VERBOSE).InfoS("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filtered)) // On success, pass the filtered result to the next filter. return next.Filter(req, filtered) } else { @@ -66,7 +66,7 @@ func (f *filter) Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend if f.nextOnFailure != nil { next = f.nextOnFailure } - klog.V(logutil.VERBOSE).Infof("onFailure %q -> %q", f.name, next.Name()) + klog.V(logutil.VERBOSE).InfoS("Filter failed", "filter", f.Name(), "next", next.Name()) // On failure, pass the initial set of pods to the next filter. return next.Filter(req, pods) } diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index ca896c5a..50564898 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -83,7 +83,7 @@ var ( nextOnFailure: &filter{ name: "drop request", filter: func(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { - klog.Infof("Dropping request %v", req) + klog.V(logutil.DEFAULT).InfoS("Request dropped", "request", req) return []*backend.PodMetrics{}, status.Errorf( codes.ResourceExhausted, "dropping request due to limited backend resources") }, @@ -92,7 +92,6 @@ var ( ) func NewScheduler(pmp PodMetricsProvider) *Scheduler { - return &Scheduler{ podMetricsProvider: pmp, filter: defaultFilter, @@ -112,13 +111,13 @@ type PodMetricsProvider interface { // Schedule finds the target pod based on metrics and the requested lora adapter. func (s *Scheduler) Schedule(req *LLMRequest) (targetPod backend.Pod, err error) { - klog.V(logutil.VERBOSE).Infof("request: %v; metrics: %+v", req, s.podMetricsProvider.AllPodMetrics()) + klog.V(logutil.VERBOSE).InfoS("Scheduling a request", "request", req, "metrics", s.podMetricsProvider.AllPodMetrics()) pods, err := s.filter.Filter(req, s.podMetricsProvider.AllPodMetrics()) if err != nil || len(pods) == 0 { return backend.Pod{}, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } - klog.V(logutil.VERBOSE).Infof("Going to randomly select a pod from the candidates: %+v", pods) + klog.V(logutil.VERBOSE).InfoS("Selecting a random pod from the candidates", "candidatePods", pods) i := rand.Intn(len(pods)) return pods[i].Pod, nil } diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index f18782d6..c83dbcb9 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -15,6 +15,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) var ( @@ -34,13 +35,19 @@ const ( ) func main() { + if err := run(); err != nil { + os.Exit(1) + } +} + +func run() error { klog.InitFlags(nil) flag.Parse() if *localServer { test.StartExtProc(port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) time.Sleep(time.Second) // wait until server is up - klog.Info("Server started") + klog.InfoS("Server started") } report, err := runner.Run( @@ -51,7 +58,8 @@ func main() { runner.WithTotalRequests(uint(*totalRequests)), ) if err != nil { - klog.Fatal(err) + klog.ErrorS(err, "Runner failed") + return err } printer := printer.ReportPrinter{ @@ -60,6 +68,7 @@ func main() { } printer.Print("summary") + return nil } func generateRequest(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { @@ -67,7 +76,7 @@ func generateRequest(mtd *desc.MethodDescriptor, callData *runner.CallData) []by req := test.GenerateRequest(modelName(int(callData.RequestNumber) % numModels)) data, err := proto.Marshal(req) if err != nil { - klog.Fatal("marshaling error: ", err) + logutil.Fatal(err, "Failed to marshal request", "request", req) } return data } diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index b91672fa..4c000722 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -14,6 +14,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) *grpc.Server { @@ -26,7 +27,7 @@ func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refresh pmc := &backend.FakePodMetricsClient{Res: pms} pp := backend.NewProvider(pmc, backend.NewK8sDataStore(backend.WithPods(pods))) if err := pp.Init(refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { - klog.Fatalf("failed to initialize: %v", err) + logutil.Fatal(err, "Failed to initialize") } return startExtProc(port, pp, models) } @@ -35,19 +36,19 @@ func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refresh func startExtProc(port int, pp *backend.Provider, models map[string]*v1alpha1.InferenceModel) *grpc.Server { lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { - klog.Fatalf("failed to listen: %v", err) + logutil.Fatal(err, "Failed to listen", "port", port) } s := grpc.NewServer() extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(pp, scheduling.NewScheduler(pp), "target-pod", &backend.FakeDataStore{Res: models})) - klog.Infof("Starting gRPC server on port :%v", port) + klog.InfoS("gRPC server starting", "port", port) reflection.Register(s) go func() { err := s.Serve(lis) if err != nil { - klog.Fatalf("Ext-proc failed with the err: %v", err) + logutil.Fatal(err, "Ext-proc failed with the err") } }() return s @@ -63,7 +64,7 @@ func GenerateRequest(model string) *extProcPb.ProcessingRequest { llmReq, err := json.Marshal(j) if err != nil { - klog.Fatal(err) + logutil.Fatal(err, "Failed to unmarshal LLM request") } req := &extProcPb.ProcessingRequest{ Request: &extProcPb.ProcessingRequest_RequestBody{ diff --git a/pkg/ext-proc/util/logging/fatal.go b/pkg/ext-proc/util/logging/fatal.go new file mode 100644 index 00000000..65926824 --- /dev/null +++ b/pkg/ext-proc/util/logging/fatal.go @@ -0,0 +1,11 @@ +package logging + +import "k8s.io/klog/v2" + +// Fatal calls klog.ErrorS followed by klog.FlushAndExit(1). +// +// This is a utility function and should not be used in production code! +func Fatal(err error, msg string, keysAndValues ...interface{}) { + klog.ErrorS(err, msg, keysAndValues...) + klog.FlushAndExit(klog.ExitFlushTimeout, 1) +} diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index ff018f28..13cddfdf 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -9,7 +9,6 @@ import ( "flag" "fmt" "io" - "log" "os" "path/filepath" "testing" @@ -35,6 +34,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" "sigs.k8s.io/yaml" ) @@ -420,7 +420,7 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP if err := serverRunner.AsRunnable( backend.NewK8sDataStore(backend.WithPods(pods)), pmc, ).Start(serverCtx); err != nil { - log.Fatalf("Failed to start ext-proc server: %v", err) + logutil.Fatal(err, "Failed to start ext-proc server") } }() @@ -431,13 +431,13 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP // Create a grpc connection conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - log.Fatalf("Failed to connect to %v: %v", address, err) + logutil.Fatal(err, "Failed to connect", "address", address) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) if err != nil { - log.Fatalf("Failed to create client: %v", err) + logutil.Fatal(err, "Failed to create client") } return client, func() { cancel() @@ -455,7 +455,7 @@ func BeforeSuit() { } cfg, err := testEnv.Start() if err != nil { - log.Fatalf("Failed to start test environment, cfg: %v error: %v", cfg, err) + logutil.Fatal(err, "Failed to start test environment", "config", cfg) } utilruntime.Must(clientgoscheme.AddToScheme(scheme)) @@ -463,16 +463,15 @@ func BeforeSuit() { k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) if err != nil { - log.Fatalf("Failed to start k8s Client: %v", err) + logutil.Fatal(err, "Failed to start k8s Client") } else if k8sClient == nil { - log.Fatalf("No error, but returned kubernetes client is nil, cfg: %v", cfg) + logutil.Fatal(nil, "No error, but returned kubernetes client is nil", "config", cfg) } // Init runtime. mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) if err != nil { - klog.ErrorS(err, "Failed to create controller manager") - klog.FlushAndExit(klog.ExitFlushTimeout, 1) + logutil.Fatal(err, "Failed to create controller manager") } serverRunner = runserver.NewDefaultExtProcServerRunner() @@ -481,17 +480,17 @@ func BeforeSuit() { serverRunner.Datastore = backend.NewK8sDataStore() if err := serverRunner.SetupWithManager(mgr); err != nil { - log.Fatalf("Failed to start server runner: %v", err) + logutil.Fatal(err, "Failed to setup server runner") } // Start the controller manager in go routine, not blocking go func() { if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - log.Fatalf("Failed to start manager: %v", err) + logutil.Fatal(err, "Failed to start manager") } }() - klog.Info("Setting up hermetic ExtProc server") + klog.InfoS("Setting up hermetic ExtProc server") klog.InitFlags(nil) flag.Parse() // Configure klog verbosity levels to print ext proc logs. @@ -501,30 +500,30 @@ func BeforeSuit() { manifestsPath := filepath.Join("..", "testdata", "inferencepool-with-model-hermetic.yaml") docs, err := readDocuments(manifestsPath) if err != nil { - log.Fatalf("Can't read object manifests at path %v, %v", manifestsPath, err) + logutil.Fatal(err, "Can't read object manifests", "path", manifestsPath) } for _, doc := range docs { inferenceModel := &v1alpha1.InferenceModel{} if err = yaml.Unmarshal(doc, inferenceModel); err != nil { - log.Fatalf("Can't unmarshal object: %v", doc) + logutil.Fatal(err, "Can't unmarshal object", "document", doc) } if inferenceModel.Kind == "InferenceModel" { - klog.Infof("Creating inference model: %+v", inferenceModel) + klog.InfoS("Creating inference model", "model", inferenceModel) if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { - log.Fatalf("unable to create inferenceModel %v: %v", inferenceModel.Name, err) + logutil.Fatal(err, "Unable to create inferenceModel", "modelName", inferenceModel.Name) } } } for _, doc := range docs { inferencePool := &v1alpha1.InferencePool{} if err = yaml.Unmarshal(doc, inferencePool); err != nil { - log.Fatalf("Can't unmarshal object: %v", doc) + logutil.Fatal(err, "Can't unmarshal object", "document", doc) } if inferencePool.Kind == "InferencePool" { - klog.Infof("Creating inference pool: %+v", inferencePool) + klog.InfoS("Creating inference pool", "pool", inferencePool) if err := k8sClient.Create(context.Background(), inferencePool); err != nil { - log.Fatalf("unable to create inferencePool %v: %v", inferencePool.Name, err) + logutil.Fatal(err, "Unable to create inferencePool", "poolName", inferencePool.Name) } } } From 8233946981074610b26193be2b51d1313820005b Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 14 Feb 2025 17:58:21 +0000 Subject: [PATCH 022/260] Add TLS support with self-signed certificate. (#335) --- pkg/ext-proc/main.go | 10 ++- pkg/ext-proc/server/runserver.go | 82 ++++++++++++++++++++++++- pkg/manifests/gateway/patch_policy.yaml | 14 +++++ test/integration/hermetic_test.go | 1 + test/testdata/envoy.yaml | 11 +++- 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 06c77af3..8f4cd8e7 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -71,7 +71,13 @@ var ( "refreshPrometheusMetricsInterval", runserver.DefaultRefreshPrometheusMetricsInterval, "interval to flush prometheus metrics") - logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") + logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") + secureServing = flag.Bool( + "secureServing", runserver.DefaultSecureServing, "Enables secure serving. Defaults to true.") + certPath = flag.String( + "certPath", "", "The path to the certificate for secure serving. The certificate and private key files "+ + "are assumed to be named tls.crt and tls.key, respectively. If not set, and secureServing is enabled, "+ + "then a self-signed certificate is used.") scheme = runtime.NewScheme() ) @@ -133,6 +139,8 @@ func run() error { RefreshMetricsInterval: *refreshMetricsInterval, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, Datastore: datastore, + SecureServing: *secureServing, + CertPath: *certPath, } if err := serverRunner.SetupWithManager(mgr); err != nil { klog.ErrorS(err, "Failed to setup ext-proc server") diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index 2d92e412..ed260b04 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -2,11 +2,19 @@ package server import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "fmt" + "math/big" "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/grpc" + "google.golang.org/grpc/credentials" "k8s.io/apimachinery/pkg/types" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" @@ -27,6 +35,8 @@ type ExtProcServerRunner struct { RefreshMetricsInterval time.Duration RefreshPrometheusMetricsInterval time.Duration Datastore *backend.K8sDatastore + SecureServing bool + CertPath string } // Default values for CLI flags in main @@ -38,6 +48,7 @@ const ( DefaultRefreshPodsInterval = 10 * time.Second // default for --refreshPodsInterval DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval DefaultRefreshPrometheusMetricsInterval = 5 * time.Second // default for --refreshPrometheusMetricsInterval + DefaultSecureServing = true // default for --secureServing ) func NewDefaultExtProcServerRunner() *ExtProcServerRunner { @@ -49,6 +60,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { RefreshPodsInterval: DefaultRefreshPodsInterval, RefreshMetricsInterval: DefaultRefreshMetricsInterval, RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, + SecureServing: DefaultSecureServing, // Datastore can be assigned later. } } @@ -107,8 +119,29 @@ func (r *ExtProcServerRunner) AsRunnable( return err } - // Init the server. - srv := grpc.NewServer() + var srv *grpc.Server + if r.SecureServing { + var cert tls.Certificate + var err error + if r.CertPath != "" { + cert, err = tls.LoadX509KeyPair(r.CertPath+"/tls.crt", r.CertPath+"/tls.key") + } else { + // Create tls based credential. + cert, err = createSelfSignedTLSCertificate() + } + if err != nil { + klog.ErrorS(err, "Failed to create self signed certificate") + return err + } + + creds := credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + // Init the server. + srv = grpc.NewServer(grpc.Creds(creds)) + } else { + srv = grpc.NewServer() + } extProcPb.RegisterExternalProcessorServer( srv, handlers.NewServer(pp, scheduling.NewScheduler(pp), r.TargetEndpointKey, r.Datastore), @@ -118,3 +151,48 @@ func (r *ExtProcServerRunner) AsRunnable( return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) })) } + +func createSelfSignedTLSCertificate() (tls.Certificate, error) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + klog.ErrorS(err, "Failed to create serial number for self-signed cert") + return tls.Certificate{}, err + } + now := time.Now() + notBefore := now.UTC() + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Inference Ext"}, + }, + NotBefore: notBefore, + NotAfter: now.Add(time.Hour * 24 * 365 * 10).UTC(), // 10 years + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + klog.ErrorS(err, "Failed to generate key for self-signed cert") + return tls.Certificate{}, err + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + klog.ErrorS(err, "Failed to create self-signed certificate") + return tls.Certificate{}, err + } + + certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + klog.ErrorS(err, "Failed to marshal private key for self-signed certificate") + return tls.Certificate{}, err + } + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + + return tls.X509KeyPair(certBytes, keyBytes) +} diff --git a/pkg/manifests/gateway/patch_policy.yaml b/pkg/manifests/gateway/patch_policy.yaml index 4a556b44..ae4fb6d8 100644 --- a/pkg/manifests/gateway/patch_policy.yaml +++ b/pkg/manifests/gateway/patch_policy.yaml @@ -35,6 +35,20 @@ spec: max_pending_requests: 40000 max_requests: 40000 + # This ensures that envoy accepts untrusted certificates. We tried to explicitly + # set TrustChainVerification to ACCEPT_UNSTRUSTED, but that actually didn't work + # and what worked is setting the common_tls_context to empty. + - type: "type.googleapis.com/envoy.config.cluster.v3.Cluster" + name: "envoyextensionpolicy/default/ext-proc-policy/extproc/0" + operation: + op: add + path: "/transport_socket" + value: + name: "envoy.transport_sockets.tls" + typed_config: + "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext" + common_tls_context: {} + - type: "type.googleapis.com/envoy.config.route.v3.RouteConfiguration" name: default/inference-gateway/llm-gw operation: diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 13cddfdf..6424663b 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -478,6 +478,7 @@ func BeforeSuit() { // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" serverRunner.Datastore = backend.NewK8sDataStore() + serverRunner.SecureServing = false if err := serverRunner.SetupWithManager(mgr); err != nil { logutil.Fatal(err, "Failed to setup server runner") diff --git a/test/testdata/envoy.yaml b/test/testdata/envoy.yaml index 700eb24c..ffb8add7 100644 --- a/test/testdata/envoy.yaml +++ b/test/testdata/envoy.yaml @@ -169,6 +169,15 @@ data: max_pending_requests: 40000 max_requests: 40000 max_retries: 1024 + # This ensures that envoy accepts untrusted certificates. We tried to explicitly + # set TrustChainVerification to ACCEPT_UNSTRUSTED, but that actually didn't work + # and what worked is setting the common_tls_context to empty. + transport_socket: + name: "envoy.transport_sockets.tls" + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + validation_context: typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions @@ -219,7 +228,7 @@ spec: - "--service-node" - "$(ENVOY_POD_NAME)" - "--log-level" - - "debug" + - "trace" - "--cpuset-threads" - "--drain-strategy" - "immediate" From 88c20f186dc9fc1eb1650592404064c7d689df46 Mon Sep 17 00:00:00 2001 From: Kunjan Date: Fri, 14 Feb 2025 13:22:21 -0800 Subject: [PATCH 023/260] Lora syncer docs (#320) * Integrate dynamic-lora-sidecar into main guide and add makefile, cloudbuild to build and publish lora-syncer image Signed-off-by: Kunjan * Add makefile and cloudbuild file to build and push lora-syncer Signed-off-by: Kunjan * Add makefile and cloudbuild file to build and push lora-syncer Signed-off-by: Kunjan * Update site-src/guides/dynamic-lora.md Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> * Update site-src/guides/dynamic-lora.md Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> * Add makefile and cloudbuild file to build and push lora-syncer Signed-off-by: Kunjan * Adds image-load and kind-load Make targets (#288) Signed-off-by: Daneyon Hansen * Add makefile and cloudbuild file to build and push lora-syncer Signed-off-by: Kunjan * Add build targets for lora syncer Signed-off-by: Kunjan * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review --------- Signed-off-by: Kunjan Signed-off-by: Daneyon Hansen Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Co-authored-by: Daneyon Hansen --- Makefile | 32 ++++ cloudbuild.yaml | 8 + .../vllm/deployment-with-syncer.yaml | 145 ++++++++++++++++++ pkg/manifests/vllm/deployment.yaml | 37 +---- site-src/guides/dynamic-lora.md | 93 +++++++++++ site-src/guides/index.md | 4 + 6 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 pkg/manifests/vllm/deployment-with-syncer.yaml create mode 100644 site-src/guides/dynamic-lora.md diff --git a/Makefile b/Makefile index b7654ed7..1d8fc531 100644 --- a/Makefile +++ b/Makefile @@ -26,11 +26,16 @@ PLATFORMS ?= linux/amd64 DOCKER_BUILDX_CMD ?= docker buildx IMAGE_BUILD_CMD ?= $(DOCKER_BUILDX_CMD) build IMAGE_BUILD_EXTRA_OPTS ?= +SYNCER_IMAGE_BUILD_EXTRA_OPTS ?= IMAGE_REGISTRY ?= us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension IMAGE_NAME := epp IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(IMAGE_NAME) IMAGE_TAG ?= $(IMAGE_REPO):$(GIT_TAG) +SYNCER_IMAGE_NAME := lora-syncer +SYNCER_IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(SYNCER_IMAGE_NAME) +SYNCER_IMAGE_TAG ?= $(SYNCER_IMAGE_REPO):$(GIT_TAG) + BASE_IMAGE ?= gcr.io/distroless/base-debian10 BUILDER_IMAGE ?= golang:1.23-alpine ifdef GO_VERSION @@ -39,9 +44,11 @@ endif ifdef EXTRA_TAG IMAGE_EXTRA_TAG ?= $(IMAGE_REPO):$(EXTRA_TAG) +SYNCER_IMAGE_EXTRA_TAG ?= $(SYNCER_IMAGE_REPO):$(EXTRA_TAG) endif ifdef IMAGE_EXTRA_TAG IMAGE_BUILD_EXTRA_OPTS += -t $(IMAGE_EXTRA_TAG) +SYNCER_IMAGE_BUILD_EXTRA_OPTS += -t $(SYNCER_IMAGE_EXTRA_TAG) endif # The name of the kind cluster to use for the "kind-load" target. @@ -171,6 +178,31 @@ image-load: image-build image-kind: image-build ## Build the EPP image and load it to kind cluster $KIND_CLUSTER ("kind" by default). kind load docker-image $(IMAGE_TAG) --name $(KIND_CLUSTER) +##@ Lora Syncer + +.PHONY: syncer-image-local-build +syncer-image-local-build: + BUILDER=$(shell $(DOCKER_BUILDX_CMD) create --use) + $(MAKE) image-build PUSH=$(PUSH) + $(DOCKER_BUILDX_CMD) rm $$BUILDER + +.PHONY: syncer-image-local-push +syncer-image-local-push: PUSH=--push +syncer-image-local-push: syncer-image-local-build + +.PHONY: syncer-image-build +syncer-image-build: + $ cd $(CURDIR)/tools/dynamic-lora-sidecar && $(IMAGE_BUILD_CMD) -t $(SYNCER_IMAGE_TAG) \ + --platform=$(PLATFORMS) \ + --build-arg BASE_IMAGE=$(BASE_IMAGE) \ + --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ + $(PUSH) \ + $(SYNCER_IMAGE_BUILD_EXTRA_OPTS) ./ + +.PHONY: syncer-image-push +syncer-image-push: PUSH=--push +syncer-image-push: syncer-image-build + ##@ Docs .PHONY: build-docs diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 2da147f4..40e45923 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -12,6 +12,14 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint + - name: lora-adapter-syncer + entrypoint: make + args: + - syncer-image-push + env: + - GIT_TAG=$_GIT_TAG + - EXTRA_TAG=$_PULL_BASE_REF + - DOCKER_BUILDX_CMD=/buildx-entrypoint substitutions: # _GIT_TAG will be filled with a git-based tag for the image, of the form vYYYYMMDD-hash, and # can be used as a substitution diff --git a/pkg/manifests/vllm/deployment-with-syncer.yaml b/pkg/manifests/vllm/deployment-with-syncer.yaml new file mode 100644 index 00000000..d6110f4b --- /dev/null +++ b/pkg/manifests/vllm/deployment-with-syncer.yaml @@ -0,0 +1,145 @@ +apiVersion: v1 +kind: Service +metadata: + name: vllm-llama2-7b-pool +spec: + selector: + app: vllm-llama2-7b-pool + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vllm-llama2-7b-pool +spec: + replicas: 3 + selector: + matchLabels: + app: vllm-llama2-7b-pool + template: + metadata: + labels: + app: vllm-llama2-7b-pool + spec: + containers: + - name: lora + image: "vllm/vllm-openai:latest" + imagePullPolicy: Always + command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] + args: + - "--model" + - "meta-llama/Llama-2-7b-hf" + - "--tensor-parallel-size" + - "1" + - "--port" + - "8000" + - "--enable-lora" + - "--max-loras" + - "4" + - "--max-cpu-loras" + - "12" + - "--lora-modules" + - '{"name": "tweet-summary-0", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' + - '{"name": "tweet-summary-1", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' + env: + - name: PORT + value: "8000" + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: token + - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING + value: "true" + ports: + - containerPort: 8000 + name: http + protocol: TCP + livenessProbe: + failureThreshold: 240 + httpGet: + path: /health + port: http + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 + readinessProbe: + failureThreshold: 600 + httpGet: + path: /health + port: http + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 + resources: + limits: + nvidia.com/gpu: 1 + requests: + nvidia.com/gpu: 1 + volumeMounts: + - mountPath: /data + name: data + - mountPath: /dev/shm + name: shm + - name: adapters + mountPath: "/adapters" + initContainers: + - name: lora-adapter-syncer + tty: true + stdin: true + image: us-central1-docker.pkg.dev/ahg-gke-dev/jobset2/lora-syncer:6dc97be + restartPolicy: Always + imagePullPolicy: Always + env: + - name: DYNAMIC_LORA_ROLLOUT_CONFIG + value: "/config/configmap.yaml" + volumeMounts: # DO NOT USE subPath + - name: config-volume + mountPath: /config + restartPolicy: Always + schedulerName: default-scheduler + terminationGracePeriodSeconds: 30 + volumes: + - name: data + emptyDir: {} + - name: shm + emptyDir: + medium: Memory + - name: adapters + emptyDir: {} + - name: config-volume + configMap: + name: dynamic-lora-config + +--- + +apiVersion: v1 +kind: ConfigMap +metadata: + name: dynamic-lora-config +data: + configmap.yaml: | + vLLMLoRAConfig: + name: sql-loras-llama + port: 8000 + ensureExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-0 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + ensureNotExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-2 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm \ No newline at end of file diff --git a/pkg/manifests/vllm/deployment.yaml b/pkg/manifests/vllm/deployment.yaml index 4af0891d..1d115f4d 100644 --- a/pkg/manifests/vllm/deployment.yaml +++ b/pkg/manifests/vllm/deployment.yaml @@ -43,18 +43,8 @@ spec: - "--max-cpu-loras" - "12" - "--lora-modules" - - "sql-lora=/adapters/hub/models--yard1--llama-2-7b-sql-lora-test/snapshots/0dfa347e8877a4d4ed19ee56c140fa518470028c/" - - "tweet-summary=/adapters/hub/models--vineetsharma--qlora-adapter-Llama-2-7b-hf-TweetSumm/snapshots/796337d8e866318c59e38f16416e3ecd11fe5403" - - 'sql-lora-0=/adapters/yard1/llama-2-7b-sql-lora-test_0' - - 'sql-lora-1=/adapters/yard1/llama-2-7b-sql-lora-test_1' - - 'sql-lora-2=/adapters/yard1/llama-2-7b-sql-lora-test_2' - - 'sql-lora-3=/adapters/yard1/llama-2-7b-sql-lora-test_3' - - 'sql-lora-4=/adapters/yard1/llama-2-7b-sql-lora-test_4' - - 'tweet-summary-0=/adapters/vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm_0' - - 'tweet-summary-1=/adapters/vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm_1' - - 'tweet-summary-2=/adapters/vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm_2' - - 'tweet-summary-3=/adapters/vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm_3' - - 'tweet-summary-4=/adapters/vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm_4' + - '{"name": "tweet-summary-0", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' + - '{"name": "tweet-summary-1", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' env: - name: PORT value: "8000" @@ -99,29 +89,6 @@ spec: name: shm - name: adapters mountPath: "/adapters" - initContainers: - - name: adapter-loader - image: ghcr.io/tomatillo-and-multiverse/adapter-puller:demo - command: ["python"] - args: - - ./pull_adapters.py - - --adapter - - yard1/llama-2-7b-sql-lora-test - - --adapter - - vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - --duplicate-count - - "5" - env: - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token - key: token - - name: HF_HOME - value: /adapters - volumeMounts: - - name: adapters - mountPath: "/adapters" restartPolicy: Always schedulerName: default-scheduler terminationGracePeriodSeconds: 30 diff --git a/site-src/guides/dynamic-lora.md b/site-src/guides/dynamic-lora.md new file mode 100644 index 00000000..ef3c2b0f --- /dev/null +++ b/site-src/guides/dynamic-lora.md @@ -0,0 +1,93 @@ +# Getting started with Gateway API Inference Extension with Dynamic lora updates on vllm + +The goal of this guide is to get a single InferencePool running with vLLM and demonstrate use of dynamic lora updating! + +### Requirements + - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher + - A cluster with: + - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, + you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). + - 3 GPUs to run the sample model server. Adjust the number of replicas in `./manifests/vllm/deployment.yaml` as needed. + +### Steps + +1. **Deploy Sample VLLM Model Server with dynamic lora update enabled and dynamic lora syncer sidecar ** + [Redeploy the vLLM deployment with Dynamic lora adapter enabled and Lora syncer sidecar and configmap](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/pkg/manifests/vllm/dynamic-lora-sidecar/deployment.yaml) + +Rest of the steps are same as [general setup](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/site-src/guides/index.md) + + +### Safely rollout v2 adapter + +1. Update the LoRA syncer ConfigMap to make the new adapter version available on the model servers. + +```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: dynamic-lora-config + data: + configmap.yaml: | + vLLMLoRAConfig: + name: sql-loras-llama + port: 8000 + ensureExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-0 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-2 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm +2. Configure a canary rollout with traffic split using LLMService. In this example, 40% of traffic for tweet-summary model will be sent to the ***tweet-summary-2*** adapter . + +```yaml +model: + name: tweet-summary + targetModels: + targetModelName: tweet-summary-0 + weight: 20 + targetModelName: tweet-summary-1 + weight: 40 + targetModelName: tweet-summary-2 + weight: 40 + +``` + +3. Finish rollout by setting the traffic to the new version 100%. +```yaml +model: + name: tweet-summary + targetModels: + targetModelName: tweet-summary-2 + weight: 100 +``` + +4. Remove v1 from dynamic lora configmap. +```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: dynamic-lora-config + data: + configmap.yaml: | + vLLMLoRAConfig: + name: sql-loras-llama + port: 8000 + ensureExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-2 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + ensureNotExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-1 + source: gs://[HUGGING FACE PATH] + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-0 + source: gs://[HUGGING FACE PATH] +``` diff --git a/site-src/guides/index.md b/site-src/guides/index.md index e4cbec6f..2cc971c6 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -19,6 +19,10 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/vllm/deployment.yaml ``` + + + + 1. **Install the Inference Extension CRDs:** ```sh From 918b96f8463273c7562a2ce80b156f7ebc3e5454 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Sat, 15 Feb 2025 00:10:20 +0000 Subject: [PATCH 024/260] Fix cloudbuild rule for the LoRA syncer image (#339) --- cloudbuild.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 40e45923..9b345c18 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -12,7 +12,7 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint - - name: lora-adapter-syncer + - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc entrypoint: make args: - syncer-image-push From 5114a5523a730a5b2003c2e9ca506762c4eaf4d6 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Sat, 15 Feb 2025 12:30:21 -0500 Subject: [PATCH 025/260] fix: Corrects release branch naming (#333) Signed-off-by: Daneyon Hansen --- .github/ISSUE_TEMPLATE/new-release.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/new-release.md b/.github/ISSUE_TEMPLATE/new-release.md index ceca9f5f..be569844 100644 --- a/.github/ISSUE_TEMPLATE/new-release.md +++ b/.github/ISSUE_TEMPLATE/new-release.md @@ -34,7 +34,7 @@ This document defines the process for releasing Gateway API Inference Extension. export RC=1 ``` -4. The vLLM image tag defaults to `0.7.2` for a release. Optionally, change the vLLM image tag. For example: +4. The vLLM image tag defaults to `v0.7.2` for a release. Set the `VLLM` environment variable if a newer [tag][vllm-tag] has been published. For example: ```shell export VLLM=0.7.3 @@ -45,16 +45,25 @@ This document defines the process for releasing Gateway API Inference Extension. 1. If needed, clone the Gateway API Inference Extension [repo][repo]. ```shell - git clone https://github.com/kubernetes-sigs/gateway-api-inference-extension.git -b main + git clone -o ${REMOTE} https://github.com/kubernetes-sigs/gateway-api-inference-extension.git ``` 2. If you already have the repo cloned, ensure it’s up-to-date and your local branch is clean. -3. Create a new release branch from the `main` branch. The release branch should be named `release-v${MAJOR}.${MINOR}`, e.g. `release-v0.1`. +3. Release Branch Handling: + - For a Release Candidate: + Create a new release branch from the `main` branch. The branch should be named `release-${MAJOR}.${MINOR}`, for example, `release-0.1`: - ```shell - git checkout -b release-v${MAJOR}.${MINOR} - ``` + ```shell + git checkout -b release-${MAJOR}.${MINOR} + ``` + + - For a Major or Minor Release: + A release branch should already exist. In this case, check out the existing branch: + + ```shell + git checkout -b release-${MAJOR}.${MINOR} ${REMOTE}/release-${MAJOR}.${MINOR} + ``` 4. Update release-specific content, generate release artifacts, and stage the changes. @@ -79,7 +88,7 @@ This document defines the process for releasing Gateway API Inference Extension. 6. Push your release branch to the Gateway API Inference Extension remote. ```shell - git push ${REMOTE} release-v${MAJOR}.${MINOR} + git push ${REMOTE} release-${MAJOR}.${MINOR} ``` 7. Tag the head of your release branch with the number. @@ -149,3 +158,4 @@ Use the following steps to announce the release. [k8s.io]: https://github.com/kubernetes/k8s.io [yaml]: https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/images/k8s-staging-gateway-api-inference-extension/images.yaml [issue]: https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/new/choose +[vllm-tag]: https://hub.docker.com/r/vllm/vllm-openai/tags From 6b42ab8e932da14840ee3a46a5a73a856a0938cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Sat, 15 Feb 2025 20:06:21 +0100 Subject: [PATCH 026/260] Use contextual logging (#337) * Use contextual logging All possible direct klog calls are removed. Instead logr.Logger is loaded from the context or passed around as an argument. * Fix log levels * server: Handle context canceled * pod_reconciler: Use TRACE log level Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> * pod_reconciler: Don't log pod not found err --------- Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> --- docs/dev.md | 80 ++++++++++------- go.mod | 2 +- pkg/ext-proc/backend/datastore.go | 10 +-- pkg/ext-proc/backend/datastore_test.go | 4 +- pkg/ext-proc/backend/fake.go | 4 +- .../backend/inferencemodel_reconciler.go | 26 +++--- .../backend/inferencemodel_reconciler_test.go | 5 +- .../backend/inferencepool_reconciler.go | 18 ++-- .../backend/inferencepool_reconciler_test.go | 5 +- pkg/ext-proc/backend/pod_reconciler.go | 9 +- pkg/ext-proc/backend/provider.go | 34 ++++---- pkg/ext-proc/backend/provider_test.go | 5 +- pkg/ext-proc/backend/vllm/metrics.go | 33 ++++--- pkg/ext-proc/backend/vllm/metrics_test.go | 5 +- pkg/ext-proc/handlers/request.go | 43 ++++++---- pkg/ext-proc/handlers/response.go | 26 ++++-- pkg/ext-proc/handlers/response_test.go | 7 +- pkg/ext-proc/handlers/server.go | 41 +++++---- pkg/ext-proc/health.go | 7 +- pkg/ext-proc/main.go | 49 ++++++----- pkg/ext-proc/metrics/metrics.go | 7 +- pkg/ext-proc/metrics/metrics_test.go | 86 ++++++++++--------- pkg/ext-proc/scheduling/filter.go | 27 +++--- pkg/ext-proc/scheduling/filter_test.go | 12 ++- pkg/ext-proc/scheduling/scheduler.go | 17 ++-- pkg/ext-proc/server/runserver.go | 21 ++--- pkg/ext-proc/server/runserver_test.go | 3 +- pkg/ext-proc/test/benchmark/benchmark.go | 35 +++++--- pkg/ext-proc/test/utils.go | 28 +++--- pkg/ext-proc/util/logging/fatal.go | 14 +-- pkg/ext-proc/util/logging/logger.go | 20 +++++ test/integration/hermetic_test.go | 54 ++++++------ 32 files changed, 436 insertions(+), 301 deletions(-) create mode 100644 pkg/ext-proc/util/logging/logger.go diff --git a/docs/dev.md b/docs/dev.md index efd2023a..2af39668 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -1,27 +1,33 @@ - ## Logging +We use `logr.Logger` interface for logging everywhere. +The logger instance is loaded from `context.Context` or passed around as an argument directly. +This is aligned with contextual logging as explained in [k8s instrumentation logging guidelines](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md). + +In other words, we explicitly don't use `klog` global logging calls. +Using `klog` log value helpers like `klog.KObj` is just fine. + ### Change log verbosity -We use the `k8s.io/klog/v2` package to manage logging. We generally follow the [k8s instrumentation logging guidelines](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md), which states "the practical default level is V(2). Developers and QE environments may wish to run at V(3) or V(4)". -To configure logging verbosity, specify the `v` flag such as `--v=2`. +To configure logging verbosity, specify the `v` flag such as `--v=2`. ### Add logs The [k8s instrumentation logging guidelines](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md) has the following definitions: -* `klog.V(0).InfoS` = `klog.InfoS` - Generally useful for this to **always** be visible to a cluster operator -* `klog.V(1).InfoS` - A reasonable default log level if you don't want verbosity. -* `klog.V(2).InfoS` - Useful steady state information about the service and important log messages that may correlate to significant changes in the system. This is the recommended default log level for most systems. -* `klog.V(3).InfoS` - Extended information about changes -* `klog.V(4).InfoS` - Debug level verbosity -* `klog.V(5).InfoS` - Trace level verbosity +- `logger.V(0).Info` = `logger.Info` - Generally useful for this to **always** be visible to a cluster operator +- `logger.V(1).Info` - A reasonable default log level if you don't want verbosity. +- `logger.V(2).Info` - Useful steady state information about the service and important log messages that may correlate to significant changes in the system. This is the recommended default log level for most systems. +- `logger.V(3).Info` - Extended information about changes +- `logger.V(4).Info` - Debug level verbosity +- `logger.V(5).Info` - Trace level verbosity We choose to simplify to the following 3 common levels. + ``` const( DEFAULT=2 @@ -33,34 +39,46 @@ const( The guidelines are written in the context of a k8s controller. Our [ext-proc](../pkg/ext-proc/) does more things such as handling requests and scraping metrics, therefore we adapt the guidelines as follows: -1. The server startup process and configuration. - * `klog.InfoS` Logging at the `V(0)` verbosity level is generally welcome here as this is only logged once at startup, and provides useful info for debugging. +1. The server startup process and configuration. + + - `logger.Info` Logging at the `V(0)` verbosity level is generally welcome here as this is only logged once at startup, and provides useful info for debugging. 2. Reconciler loops. The reconciler loops watch for CR changes such as the `InferenceModel` CR. And given changes in these CRs significantly affect the behavior of the extension, we recommend using v=1 verbosity level as default, and sparsely use higher verbosity levels. - - * `klog.V(DEFAULT).InfoS` - * Default log level in the reconcilers. - * Information about config (listening on X, watching Y) - * Errors that repeat frequently that relate to conditions that can be corrected (e.g., inference model not initialized yet) - * System state changing (adding/removing objects in the data store) - * `V(VERBOSE)` and above: Use your best judgement. + + - `logger.V(DEFAULT)` + - Default log level in the reconcilers. + - Information about config (listening on X, watching Y) + - Errors that repeat frequently that relate to conditions that can be corrected (e.g., inference model not initialized yet) + - System state changing (adding/removing objects in the data store) + - `logger.V(VERBOSE)` and above: Use your best judgement. 3. Inference request handling. These requests are expected to be much higher volume than the control flow in the reconcilers and therefore we should be mindful of log spamming. We recommend using v=2 to log important info about a request, such as the HTTP response code, and higher verbosity levels for less important info. - * `klog.V(DEFAULT).InfoS` - * Logging the status code of an HTTP request - * Important decision making such as picking the target model, target pod - * `klog.V(VERBOSE).InfoS` - * Detailed request scheduling algorithm operations, such as running the filtering logic - * `V(DEBUG)` and above: Use your best judgement. + - `logger.V(DEFAULT)` + - Logging the status code of an HTTP request + - Important decision making such as picking the target model, target pod + - `logger.V(VERBOSE)` + - Detailed request scheduling algorithm operations, such as running the filtering logic + - `logger.V(DEBUG)` and above: Use your best judgement. 4. Metric scraping loops. These loops run at a very high frequency, and logs can be very spammy if not handled properly. - * `klog.V(TRACE).InfoS` - * Transient errors/warnings, such as failure to get response from a pod. - * Important state changes, such as updating a metric. -5. Misc + - `logger.V(TRACE)` + - Transient errors/warnings, such as failure to get response from a pod. + - Important state changes, such as updating a metric. + +5. Misc 1. Periodic (every 5s) debug loop which prints the current pods and metrics. - * `klog.WarningS` If the metrics are not fresh enough, which indicates an error occurred during the metric scraping loop. - * `klog.V(DEBUG).InfoS` - * This is very important to debug the request scheduling algorithm, and yet not spammy compared to the metric scraping loop logs. \ No newline at end of file + - `logger.V(DEFAULT).Error` If the metrics are not fresh enough, which indicates an error occurred during the metric scraping loop. + - `logger.V(DEBUG)` + - This is very important to debug the request scheduling algorithm, and yet not spammy compared to the metric scraping loop logs. + +### Passing Logger Around + +You can pass around a `context.Context` that contains a logger or a `logr.Logger` instance directly. +You need to make the call which one to use. Passing a `context.Context` is more standard, +on the other hand you then need to call `log.FromContext` everywhere. + +As `logger.V` calls are cummulative, i.e. `logger.V(2).V(3)` results in `logger.V(5)`, +a logger should be passed around with no verbosity level set so that `logger.V(DEFAULT)` +actually uses `DEFAULT` verbosity level. diff --git a/go.mod b/go.mod index d8b143ec..a59a28cc 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/bojand/ghz v0.120.0 github.com/elastic/crd-ref-docs v0.1.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 + github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.6.0 github.com/jhump/protoreflect v1.17.0 github.com/onsi/ginkgo/v2 v2.22.2 @@ -61,7 +62,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/backend/datastore.go index a54833bc..a75e7e43 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/backend/datastore.go @@ -7,10 +7,11 @@ import ( "strconv" "sync" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" - "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -87,7 +88,7 @@ func (ds *K8sDatastore) HasSynced() bool { return ds.inferencePool != nil } -func RandomWeightedDraw(model *v1alpha1.InferenceModel, seed int64) string { +func RandomWeightedDraw(logger logr.Logger, model *v1alpha1.InferenceModel, seed int64) string { var weights int32 source := rand.NewSource(rand.Int63()) @@ -98,7 +99,7 @@ func RandomWeightedDraw(model *v1alpha1.InferenceModel, seed int64) string { for _, model := range model.Spec.TargetModels { weights += *model.Weight } - klog.V(logutil.VERBOSE).InfoS("Weights for model computed", "model", model.Name, "weights", weights) + logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) randomVal := r.Int31n(weights) for _, model := range model.Spec.TargetModels { if randomVal < *model.Weight { @@ -128,7 +129,7 @@ func (ds *K8sDatastore) flushPodsAndRefetch(ctx context.Context, ctrlClient clie LabelSelector: selectorFromInferencePoolSelector(newServerPool.Spec.Selector), Namespace: newServerPool.Namespace, }); err != nil { - klog.Error(err, "error listing clients") + log.FromContext(ctx).V(logutil.DEFAULT).Error(err, "Failed to list clients") } ds.pods.Clear() @@ -139,7 +140,6 @@ func (ds *K8sDatastore) flushPodsAndRefetch(ctx context.Context, ctrlClient clie } ds.pods.Store(pod, true) } - } func selectorFromInferencePoolSelector(selector map[v1alpha1.LabelKey]v1alpha1.LabelValue) labels.Selector { diff --git a/pkg/ext-proc/backend/datastore_test.go b/pkg/ext-proc/backend/datastore_test.go index 0fc5da1a..9f74226a 100644 --- a/pkg/ext-proc/backend/datastore_test.go +++ b/pkg/ext-proc/backend/datastore_test.go @@ -5,6 +5,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func TestHasSynced(t *testing.T) { @@ -46,6 +47,7 @@ func TestHasSynced(t *testing.T) { } func TestRandomWeightedDraw(t *testing.T) { + logger := logutil.NewTestLogger() tests := []struct { name string model *v1alpha1.InferenceModel @@ -118,7 +120,7 @@ func TestRandomWeightedDraw(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { for range 10000 { - model := RandomWeightedDraw(test.model, seedVal) + model := RandomWeightedDraw(logger, test.model, seedVal) if model != test.want { t.Errorf("Model returned!: %v", model) break diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index 7ab8a464..2c0757db 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -3,7 +3,7 @@ package backend import ( "context" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -17,7 +17,7 @@ func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod Pod, existi if err, ok := f.Err[pod]; ok { return nil, err } - klog.V(logutil.VERBOSE).InfoS("Fetching metrics for pod", "pod", pod, "existing", existing, "new", f.Res[pod]) + log.FromContext(ctx).V(logutil.VERBOSE).Info("Fetching metrics for pod", "pod", pod, "existing", existing, "new", f.Res[pod]) return f.Res[pod], nil } diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/backend/inferencemodel_reconciler.go index 72ea063e..4959845c 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler.go @@ -3,13 +3,14 @@ package backend import ( "context" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" - "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -27,38 +28,39 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque return ctrl.Result{}, nil } - klogV := klog.V(logutil.DEFAULT) - klogV.InfoS("Reconciling InferenceModel", "name", req.NamespacedName) + logger := log.FromContext(ctx) + loggerDefault := logger.V(logutil.DEFAULT) + loggerDefault.Info("Reconciling InferenceModel", "name", req.NamespacedName) infModel := &v1alpha1.InferenceModel{} if err := c.Get(ctx, req.NamespacedName, infModel); err != nil { if errors.IsNotFound(err) { - klogV.InfoS("InferenceModel not found. Removing from datastore since object must be deleted", "name", req.NamespacedName) + loggerDefault.Info("InferenceModel not found. Removing from datastore since object must be deleted", "name", req.NamespacedName) c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) return ctrl.Result{}, nil } - klogV.ErrorS(err, "Unable to get InferenceModel", "name", req.NamespacedName) + loggerDefault.Error(err, "Unable to get InferenceModel", "name", req.NamespacedName) return ctrl.Result{}, err } else if !infModel.DeletionTimestamp.IsZero() { - klogV.InfoS("InferenceModel is marked for deletion. Removing from datastore", "name", req.NamespacedName) + loggerDefault.Info("InferenceModel is marked for deletion. Removing from datastore", "name", req.NamespacedName) c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) return ctrl.Result{}, nil } - c.updateDatastore(infModel) + c.updateDatastore(logger, infModel) return ctrl.Result{}, nil } -func (c *InferenceModelReconciler) updateDatastore(infModel *v1alpha1.InferenceModel) { - klogV := klog.V(logutil.DEFAULT) +func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel *v1alpha1.InferenceModel) { + loggerDefault := logger.V(logutil.DEFAULT) if infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name { - klogV.InfoS("Updating datastore", "poolRef", infModel.Spec.PoolRef, "serverPoolName", c.PoolNamespacedName) - klogV.InfoS("Adding/Updating InferenceModel", "modelName", infModel.Spec.ModelName) + loggerDefault.Info("Updating datastore", "poolRef", infModel.Spec.PoolRef, "serverPoolName", c.PoolNamespacedName) + loggerDefault.Info("Adding/Updating InferenceModel", "modelName", infModel.Spec.ModelName) c.Datastore.InferenceModels.Store(infModel.Spec.ModelName, infModel) return } - klogV.InfoS("Removing/Not adding InferenceModel", "modelName", infModel.Spec.ModelName) + loggerDefault.Info("Removing/Not adding InferenceModel", "modelName", infModel.Spec.ModelName) // If we get here. The model is not relevant to this pool, remove. c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) } diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index c5ef8d14..4e195818 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -13,6 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) var ( @@ -46,6 +47,8 @@ var ( ) func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { + logger := logutil.NewTestLogger() + tests := []struct { name string datastore *K8sDatastore @@ -135,7 +138,7 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { Datastore: test.datastore, PoolNamespacedName: types.NamespacedName{Name: test.datastore.inferencePool.Name}, } - reconciler.updateDatastore(test.incomingService) + reconciler.updateDatastore(logger, test.incomingService) if ok := mapsEqual(reconciler.Datastore.InferenceModels, test.wantInferenceModels); !ok { t.Error("Maps are not equal") diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/backend/inferencepool_reconciler.go index 9504b4e0..e44a278a 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler.go @@ -4,12 +4,14 @@ import ( "context" "reflect" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -29,29 +31,31 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques if req.NamespacedName.Name != c.PoolNamespacedName.Name || req.NamespacedName.Namespace != c.PoolNamespacedName.Namespace { return ctrl.Result{}, nil } - klogV := klog.V(logutil.DEFAULT) - klogV.InfoS("Reconciling InferencePool", "name", req.NamespacedName) + + logger := log.FromContext(ctx) + loggerDefault := logger.V(logutil.DEFAULT) + loggerDefault.Info("Reconciling InferencePool", "name", req.NamespacedName) serverPool := &v1alpha1.InferencePool{} if err := c.Get(ctx, req.NamespacedName, serverPool); err != nil { - klogV.ErrorS(err, "Unable to get InferencePool", "name", req.NamespacedName) + loggerDefault.Error(err, "Unable to get InferencePool", "name", req.NamespacedName) return ctrl.Result{}, err } if c.Datastore.inferencePool == nil || !reflect.DeepEqual(serverPool.Spec.Selector, c.Datastore.inferencePool.Spec.Selector) { - c.updateDatastore(serverPool) + c.updateDatastore(logger, serverPool) c.Datastore.flushPodsAndRefetch(ctx, c.Client, serverPool) } else { - c.updateDatastore(serverPool) + c.updateDatastore(logger, serverPool) } return ctrl.Result{}, nil } -func (c *InferencePoolReconciler) updateDatastore(serverPool *v1alpha1.InferencePool) { +func (c *InferencePoolReconciler) updateDatastore(logger logr.Logger, serverPool *v1alpha1.InferencePool) { pool, _ := c.Datastore.getInferencePool() if pool == nil || serverPool.ObjectMeta.ResourceVersion != pool.ObjectMeta.ResourceVersion { - klog.V(logutil.DEFAULT).InfoS("Updating inference pool", "target", klog.KMetadata(&serverPool.ObjectMeta)) + logger.V(logutil.DEFAULT).Info("Updating inference pool", "target", klog.KMetadata(&serverPool.ObjectMeta)) c.Datastore.setInferencePool(serverPool) } } diff --git a/pkg/ext-proc/backend/inferencepool_reconciler_test.go b/pkg/ext-proc/backend/inferencepool_reconciler_test.go index f16524a5..1da7d61b 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler_test.go @@ -6,6 +6,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) var ( @@ -41,6 +42,8 @@ var ( ) func TestUpdateDatastore_InferencePoolReconciler(t *testing.T) { + logger := logutil.NewTestLogger() + tests := []struct { name string datastore *K8sDatastore @@ -74,7 +77,7 @@ func TestUpdateDatastore_InferencePoolReconciler(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { inferencePoolReconciler := &InferencePoolReconciler{Datastore: test.datastore} - inferencePoolReconciler.updateDatastore(test.incomingPool) + inferencePoolReconciler.updateDatastore(logger, test.incomingPool) gotPool := inferencePoolReconciler.Datastore.inferencePool if !reflect.DeepEqual(gotPool, test.wantPool) { diff --git a/pkg/ext-proc/backend/pod_reconciler.go b/pkg/ext-proc/backend/pod_reconciler.go index 60d014ce..b914ea8d 100644 --- a/pkg/ext-proc/backend/pod_reconciler.go +++ b/pkg/ext-proc/backend/pod_reconciler.go @@ -8,9 +8,9 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/record" - "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -23,24 +23,25 @@ type PodReconciler struct { } func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) inferencePool, err := c.Datastore.getInferencePool() if err != nil { - klog.V(logutil.DEFAULT).Infof("Skipping reconciling Pod because the InferencePool is not available yet: %v", err) + logger.V(logutil.TRACE).Info("Skipping reconciling Pod because the InferencePool is not available yet", "error", err) // When the inferencePool is initialized it lists the appropriate pods and populates the datastore, so no need to requeue. return ctrl.Result{}, nil } else if inferencePool.Namespace != req.Namespace { return ctrl.Result{}, nil } - klog.V(logutil.VERBOSE).Info("reconciling Pod", req.NamespacedName) + logger.V(logutil.VERBOSE).Info("Pod being reconciled", "name", req.NamespacedName) pod := &corev1.Pod{} if err := c.Get(ctx, req.NamespacedName, pod); err != nil { - klog.Error(err, ": unable to get pod") if apierrors.IsNotFound(err) { c.Datastore.pods.Delete(pod) return ctrl.Result{}, nil } + logger.V(logutil.DEFAULT).Error(err, "Unable to get pod", "name", req.NamespacedName) return ctrl.Result{}, err } diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index d64b80b3..ce738986 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -6,8 +6,8 @@ import ( "sync" "time" + "github.com/go-logr/logr" "go.uber.org/multierr" - klog "k8s.io/klog/v2" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -59,14 +59,14 @@ func (p *Provider) GetPodMetrics(pod Pod) (*PodMetrics, bool) { return nil, false } -func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { +func (p *Provider) Init(logger logr.Logger, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { p.refreshPodsOnce() - if err := p.refreshMetricsOnce(); err != nil { - klog.ErrorS(err, "Failed to init metrics") + if err := p.refreshMetricsOnce(logger); err != nil { + logger.Error(err, "Failed to init metrics") } - klog.InfoS("Initialized pods and metrics", "metrics", p.AllPodMetrics()) + logger.Info("Initialized pods and metrics", "metrics", p.AllPodMetrics()) // periodically refresh pods go func() { @@ -80,8 +80,8 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshProm go func() { for { time.Sleep(refreshMetricsInterval) - if err := p.refreshMetricsOnce(); err != nil { - klog.V(logutil.TRACE).ErrorS(err, "Failed to refresh metrics") + if err := p.refreshMetricsOnce(logger); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Failed to refresh metrics") } } }() @@ -90,16 +90,16 @@ func (p *Provider) Init(refreshPodsInterval, refreshMetricsInterval, refreshProm go func() { for { time.Sleep(refreshPrometheusMetricsInterval) - p.flushPrometheusMetricsOnce() + p.flushPrometheusMetricsOnce(logger) } }() // Periodically print out the pods and metrics for DEBUGGING. - if klogV := klog.V(logutil.DEBUG); klogV.Enabled() { + if logger := logger.V(logutil.DEBUG); logger.Enabled() { go func() { for { time.Sleep(5 * time.Second) - klogV.InfoS("Current Pods and metrics gathered", "metrics", p.AllPodMetrics()) + logger.Info("Current Pods and metrics gathered", "metrics", p.AllPodMetrics()) } }() } @@ -137,20 +137,20 @@ func (p *Provider) refreshPodsOnce() { p.datastore.pods.Range(addNewPods) } -func (p *Provider) refreshMetricsOnce() error { - klogV := klog.V(logutil.TRACE) +func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { + loggerTrace := logger.V(logutil.TRACE) ctx, cancel := context.WithTimeout(context.Background(), fetchMetricsTimeout) defer cancel() start := time.Now() defer func() { d := time.Since(start) // TODO: add a metric instead of logging - klogV.InfoS("Metrics refreshed", "duration", d) + loggerTrace.Info("Metrics refreshed", "duration", d) }() var wg sync.WaitGroup errCh := make(chan error) processOnePod := func(key, value any) bool { - klogV.InfoS("Pod and metric being processed", "pod", key, "metric", value) + loggerTrace.Info("Pod and metric being processed", "pod", key, "metric", value) pod := key.(Pod) existing := value.(*PodMetrics) wg.Add(1) @@ -162,7 +162,7 @@ func (p *Provider) refreshMetricsOnce() error { return } p.UpdatePodMetrics(pod, updated) - klogV.InfoS("Updated metrics for pod", "pod", pod, "metrics", updated.Metrics) + loggerTrace.Info("Updated metrics for pod", "pod", pod, "metrics", updated.Metrics) }() return true } @@ -185,8 +185,8 @@ func (p *Provider) refreshMetricsOnce() error { return errs } -func (p *Provider) flushPrometheusMetricsOnce() { - klog.V(logutil.DEBUG).InfoS("Flushing Prometheus Metrics") +func (p *Provider) flushPrometheusMetricsOnce(logger logr.Logger) { + logger.V(logutil.DEBUG).Info("Flushing Prometheus Metrics") pool, _ := p.datastore.getInferencePool() if pool == nil { diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/ext-proc/backend/provider_test.go index ddd7f0d6..95575046 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/ext-proc/backend/provider_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) var ( @@ -38,6 +39,8 @@ var ( ) func TestProvider(t *testing.T) { + logger := logutil.NewTestLogger() + tests := []struct { name string pmc PodMetricsClient @@ -90,7 +93,7 @@ func TestProvider(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { p := NewProvider(test.pmc, test.datastore) - err := p.Init(time.Millisecond, time.Millisecond, time.Millisecond) + err := p.Init(logger, time.Millisecond, time.Millisecond, time.Millisecond) if test.initErr != (err != nil) { t.Fatalf("Unexpected error, got: %v, want: %v", err, test.initErr) } diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index 4c3804ce..4558a664 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -9,10 +9,11 @@ import ( "strings" "time" + "github.com/go-logr/logr" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" "go.uber.org/multierr" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -40,17 +41,20 @@ func (p *PodMetricsClientImpl) FetchMetrics( pod backend.Pod, existing *backend.PodMetrics, ) (*backend.PodMetrics, error) { + logger := log.FromContext(ctx) + loggerDefault := logger.V(logutil.DEFAULT) + // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. url := fmt.Sprintf("http://%s/metrics", pod.Address) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - klog.V(logutil.DEFAULT).ErrorS(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) + loggerDefault.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) return nil, fmt.Errorf("failed to create request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { - klog.V(logutil.DEFAULT).ErrorS(err, "Failed to fetch metrics", "pod", pod) + loggerDefault.Error(err, "Failed to fetch metrics", "pod", pod) return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod, err) } defer func() { @@ -58,7 +62,7 @@ func (p *PodMetricsClientImpl) FetchMetrics( }() if resp.StatusCode != http.StatusOK { - klog.V(logutil.DEFAULT).ErrorS(nil, "Unexpected status code returned", "pod", pod, "statusCode", resp.StatusCode) + loggerDefault.Error(nil, "Unexpected status code returned", "pod", pod, "statusCode", resp.StatusCode) return nil, fmt.Errorf("unexpected status code from %s: %v", pod, resp.StatusCode) } @@ -67,35 +71,36 @@ func (p *PodMetricsClientImpl) FetchMetrics( if err != nil { return nil, err } - return promToPodMetrics(metricFamilies, existing) + return promToPodMetrics(logger, metricFamilies, existing) } // promToPodMetrics updates internal pod metrics with scraped prometheus metrics. // A combined error is returned if errors occur in one or more metric processing. // it returns a new PodMetrics pointer which can be used to atomically update the pod metrics map. func promToPodMetrics( + logger logr.Logger, metricFamilies map[string]*dto.MetricFamily, existing *backend.PodMetrics, ) (*backend.PodMetrics, error) { var errs error updated := existing.Clone() - runningQueueSize, err := getLatestMetric(metricFamilies, RunningQueueSizeMetricName) + runningQueueSize, err := getLatestMetric(logger, metricFamilies, RunningQueueSizeMetricName) errs = multierr.Append(errs, err) if err == nil { updated.RunningQueueSize = int(runningQueueSize.GetGauge().GetValue()) } - waitingQueueSize, err := getLatestMetric(metricFamilies, WaitingQueueSizeMetricName) + waitingQueueSize, err := getLatestMetric(logger, metricFamilies, WaitingQueueSizeMetricName) errs = multierr.Append(errs, err) if err == nil { updated.WaitingQueueSize = int(waitingQueueSize.GetGauge().GetValue()) } - cachePercent, err := getLatestMetric(metricFamilies, KVCacheUsagePercentMetricName) + cachePercent, err := getLatestMetric(logger, metricFamilies, KVCacheUsagePercentMetricName) errs = multierr.Append(errs, err) if err == nil { updated.KVCacheUsagePercent = cachePercent.GetGauge().GetValue() } - loraMetrics, _, err := getLatestLoraMetric(metricFamilies) + loraMetrics, _, err := getLatestLoraMetric(logger, metricFamilies) errs = multierr.Append(errs, err) /* TODO: uncomment once this is available in vllm. kvCap, _, err := getGaugeLatestValue(metricFamilies, KvCacheMaxTokenCapacityMetricName) @@ -135,10 +140,10 @@ func promToPodMetrics( // reason its specially fetched is because each label key value pair permutation generates new series // and only most recent is useful. The value of each series is the creation timestamp so we can // retrieve the latest by sorting the value. -func getLatestLoraMetric(metricFamilies map[string]*dto.MetricFamily) (*dto.Metric, time.Time, error) { +func getLatestLoraMetric(logger logr.Logger, metricFamilies map[string]*dto.MetricFamily) (*dto.Metric, time.Time, error) { loraRequests, ok := metricFamilies[LoraRequestInfoMetricName] if !ok { - klog.V(logutil.DEFAULT).ErrorS(nil, "Metric family not found", "name", LoraRequestInfoMetricName) + logger.V(logutil.DEFAULT).Error(nil, "Metric family not found", "name", LoraRequestInfoMetricName) return nil, time.Time{}, fmt.Errorf("metric family %q not found", LoraRequestInfoMetricName) } var latestTs float64 @@ -154,10 +159,10 @@ func getLatestLoraMetric(metricFamilies map[string]*dto.MetricFamily) (*dto.Metr // getLatestMetric gets the latest metric of a family. This should be used to get the latest Gauge metric. // Since vllm doesn't set the timestamp in metric, this metric essentially gets the first metric. -func getLatestMetric(metricFamilies map[string]*dto.MetricFamily, metricName string) (*dto.Metric, error) { +func getLatestMetric(logger logr.Logger, metricFamilies map[string]*dto.MetricFamily, metricName string) (*dto.Metric, error) { mf, ok := metricFamilies[metricName] if !ok { - klog.V(logutil.DEFAULT).ErrorS(nil, "Metric family not found", "name", metricName) + logger.V(logutil.DEFAULT).Error(nil, "Metric family not found", "name", metricName) return nil, fmt.Errorf("metric family %q not found", metricName) } if len(mf.GetMetric()) == 0 { @@ -171,6 +176,6 @@ func getLatestMetric(metricFamilies map[string]*dto.MetricFamily, metricName str latest = m } } - klog.V(logutil.TRACE).InfoS("Metric value selected", "value", latest, "metric", metricName) + logger.V(logutil.TRACE).Info("Metric value selected", "value", latest, "metric", metricName) return latest, nil } diff --git a/pkg/ext-proc/backend/vllm/metrics_test.go b/pkg/ext-proc/backend/vllm/metrics_test.go index 3d4225e8..0a718cd7 100644 --- a/pkg/ext-proc/backend/vllm/metrics_test.go +++ b/pkg/ext-proc/backend/vllm/metrics_test.go @@ -8,9 +8,12 @@ import ( "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func TestPromToPodMetrics(t *testing.T) { + logger := logutil.NewTestLogger() + testCases := []struct { name string metricFamilies map[string]*dto.MetricFamily @@ -219,7 +222,7 @@ func TestPromToPodMetrics(t *testing.T) { } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - updated, err := promToPodMetrics(tc.metricFamilies, tc.initialPodMetrics) + updated, err := promToPodMetrics(logger, tc.metricFamilies, tc.initialPodMetrics) if tc.expectedErr != nil { assert.Error(t, err) } else { diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index a36f7ae3..8ce2956f 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "errors" "fmt" @@ -9,7 +10,7 @@ import ( configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/protobuf/types/known/structpb" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" @@ -18,25 +19,30 @@ import ( // HandleRequestBody handles body of the request to the backend server, such as parsing the "model" // parameter. // Envoy sends the request body to ext proc before sending the request to the backend server. -func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - klogV := klog.V(logutil.VERBOSE) - klogV.InfoS("Handling request body") +func (s *Server) HandleRequestBody( + ctx context.Context, + reqCtx *RequestContext, + req *extProcPb.ProcessingRequest, +) (*extProcPb.ProcessingResponse, error) { + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Handling request body") // Unmarshal request body (must be JSON). v := req.Request.(*extProcPb.ProcessingRequest_RequestBody) var rb map[string]interface{} if err := json.Unmarshal(v.RequestBody.Body, &rb); err != nil { - klog.V(logutil.DEFAULT).ErrorS(err, "Error unmarshaling request body") + logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") return nil, fmt.Errorf("error unmarshaling request body: %v", err) } - klogV.InfoS("Request body unmarshalled", "body", rb) + loggerVerbose.Info("Request body unmarshalled", "body", rb) // Resolve target models. model, ok := rb["model"].(string) if !ok { return nil, errors.New("model not found in request") } - klogV.InfoS("Model requested", "model", model) + loggerVerbose.Info("Model requested", "model", model) modelName := model // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. @@ -47,7 +53,7 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces return nil, fmt.Errorf("error finding a model object in InferenceModel for input %v", model) } if len(modelObj.Spec.TargetModels) > 0 { - modelName = backend.RandomWeightedDraw(modelObj, 0) + modelName = backend.RandomWeightedDraw(logger, modelObj, 0) if modelName == "" { return nil, fmt.Errorf("error getting target model name for model %v", modelObj.Name) } @@ -57,7 +63,7 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces ResolvedTargetModel: modelName, Critical: backend.IsCritical(modelObj), } - klogV.InfoS("LLM request assembled", "request", llmReq) + loggerVerbose.Info("LLM request assembled", "request", llmReq) requestBody := v.RequestBody.Body var err error @@ -66,17 +72,18 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces rb["model"] = llmReq.ResolvedTargetModel requestBody, err = json.Marshal(rb) if err != nil { - klog.V(logutil.DEFAULT).ErrorS(err, "Error marshaling request body") + logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") return nil, fmt.Errorf("error marshaling request body: %v", err) } - klogV.InfoS("Updated request body marshalled", "body", string(requestBody)) + loggerVerbose.Info("Updated request body marshalled", "body", string(requestBody)) } - targetPod, err := s.scheduler.Schedule(llmReq) + targetPod, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { return nil, fmt.Errorf("failed to find target pod: %w", err) } - klogV.InfoS("Target model and pod selected", "model", llmReq.ResolvedTargetModel, "pod", targetPod) + logger.V(logutil.DEFAULT).Info("Request handled", + "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) reqCtx.Model = llmReq.Model reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel @@ -102,7 +109,7 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces } // Print headers for debugging for _, header := range headers { - klog.V(logutil.DEBUG).InfoS("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) + logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) } resp := &extProcPb.ProcessingResponse{ @@ -136,10 +143,14 @@ func (s *Server) HandleRequestBody(reqCtx *RequestContext, req *extProcPb.Proces return resp, nil } -func HandleRequestHeaders(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) *extProcPb.ProcessingResponse { +func HandleRequestHeaders( + ctx context.Context, + reqCtx *RequestContext, + req *extProcPb.ProcessingRequest, +) *extProcPb.ProcessingResponse { r := req.Request h := r.(*extProcPb.ProcessingRequest_RequestHeaders) - klog.V(logutil.VERBOSE).InfoS("Handling request headers", "headers", h) + log.FromContext(ctx).V(logutil.VERBOSE).Info("Handling request headers", "headers", h) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestHeaders{ diff --git a/pkg/ext-proc/handlers/response.go b/pkg/ext-proc/handlers/response.go index 012b0b8d..06da8106 100644 --- a/pkg/ext-proc/handlers/response.go +++ b/pkg/ext-proc/handlers/response.go @@ -1,20 +1,26 @@ package handlers import ( + "context" "encoding/json" "fmt" configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) // HandleResponseHeaders processes response headers from the backend model server. -func (s *Server) HandleResponseHeaders(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - klog.V(logutil.VERBOSE).InfoS("Processing ResponseHeaders") +func (s *Server) HandleResponseHeaders( + ctx context.Context, + reqCtx *RequestContext, + req *extProcPb.ProcessingRequest, +) (*extProcPb.ProcessingResponse, error) { + loggerVerbose := log.FromContext(ctx).V(logutil.VERBOSE) + loggerVerbose.Info("Processing ResponseHeaders") h := req.Request.(*extProcPb.ProcessingRequest_ResponseHeaders) - klog.V(logutil.VERBOSE).InfoS("Headers before", "headers", h) + loggerVerbose.Info("Headers before", "headers", h) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseHeaders{ @@ -65,8 +71,14 @@ func (s *Server) HandleResponseHeaders(reqCtx *RequestContext, req *extProcPb.Pr "completion_tokens": 100 } }*/ -func (s *Server) HandleResponseBody(reqCtx *RequestContext, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - klog.V(logutil.VERBOSE).InfoS("Processing HandleResponseBody") +func (s *Server) HandleResponseBody( + ctx context.Context, + reqCtx *RequestContext, + req *extProcPb.ProcessingRequest, +) (*extProcPb.ProcessingResponse, error) { + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Processing HandleResponseBody") body := req.Request.(*extProcPb.ProcessingRequest_ResponseBody) res := Response{} @@ -81,7 +93,7 @@ func (s *Server) HandleResponseBody(reqCtx *RequestContext, req *extProcPb.Proce // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) // will add the processing for streaming case. reqCtx.ResponseComplete = true - klog.V(logutil.VERBOSE).InfoS("Response generated", "response", res) + loggerVerbose.Info("Response generated", "response", res) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseBody{ diff --git a/pkg/ext-proc/handlers/response_test.go b/pkg/ext-proc/handlers/response_test.go index df338066..67875e05 100644 --- a/pkg/ext-proc/handlers/response_test.go +++ b/pkg/ext-proc/handlers/response_test.go @@ -1,10 +1,12 @@ package handlers import ( + "context" "testing" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/google/go-cmp/cmp" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( @@ -34,6 +36,8 @@ const ( ) func TestHandleResponseBody(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + tests := []struct { name string req *extProcPb.ProcessingRequest_ResponseBody @@ -70,8 +74,7 @@ func TestHandleResponseBody(t *testing.T) { t.Run(test.name, func(t *testing.T) { server := &Server{} reqCtx := &RequestContext{} - _, err := server.HandleResponseBody(reqCtx, &extProcPb.ProcessingRequest{Request: test.req}) - + _, err := server.HandleResponseBody(ctx, reqCtx, &extProcPb.ProcessingRequest{Request: test.req}) if err != nil { if !test.wantErr { t.Fatalf("HandleResponseBody returned unexpected error: %v, want %v", err, test.wantErr) diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index a3cfcada..6be747da 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -1,6 +1,8 @@ package handlers import ( + "context" + "errors" "io" "time" @@ -8,7 +10,7 @@ import ( envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" @@ -37,7 +39,7 @@ type Server struct { } type Scheduler interface { - Schedule(b *scheduling.LLMRequest) (targetPod backend.Pod, err error) + Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod backend.Pod, err error) } // PodProvider is an interface to provide set of pods in the backend and information such as metrics. @@ -51,8 +53,11 @@ type ModelDataStore interface { } func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { - klog.V(logutil.VERBOSE).InfoS("Processing") ctx := srv.Context() + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Processing") + // Create request context to share states during life time of an HTTP request. // See https://github.com/envoyproxy/envoy/issues/17540. reqCtx := &RequestContext{} @@ -65,13 +70,13 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } req, err := srv.Recv() - if err == io.EOF { + if err == io.EOF || errors.Is(err, context.Canceled) { return nil } if err != nil { // This error occurs very frequently, though it doesn't seem to have any impact. // TODO Figure out if we can remove this noise. - klog.V(logutil.VERBOSE).ErrorS(err, "Cannot receive stream request") + loggerVerbose.Error(err, "Cannot receive stream request") return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) } @@ -79,34 +84,34 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { switch v := req.Request.(type) { case *extProcPb.ProcessingRequest_RequestHeaders: reqCtx.RequestReceivedTimestamp = time.Now() - resp = HandleRequestHeaders(reqCtx, req) - klog.V(logutil.VERBOSE).InfoS("Request context after HandleRequestHeaders", "context", reqCtx) + resp = HandleRequestHeaders(ctx, reqCtx, req) + loggerVerbose.Info("Request context after HandleRequestHeaders", "context", reqCtx) case *extProcPb.ProcessingRequest_RequestBody: - resp, err = s.HandleRequestBody(reqCtx, req) + resp, err = s.HandleRequestBody(ctx, reqCtx, req) if err == nil { metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) } - klog.V(logutil.VERBOSE).InfoS("Request context after HandleRequestBody", "context", reqCtx) + loggerVerbose.Info("Request context after HandleRequestBody", "context", reqCtx) case *extProcPb.ProcessingRequest_ResponseHeaders: - resp, err = s.HandleResponseHeaders(reqCtx, req) - klog.V(logutil.VERBOSE).InfoS("Request context after HandleResponseHeaders", "context", reqCtx) + resp, err = s.HandleResponseHeaders(ctx, reqCtx, req) + loggerVerbose.Info("Request context after HandleResponseHeaders", "context", reqCtx) case *extProcPb.ProcessingRequest_ResponseBody: - resp, err = s.HandleResponseBody(reqCtx, req) + resp, err = s.HandleResponseBody(ctx, reqCtx, req) if err == nil && reqCtx.ResponseComplete { reqCtx.ResponseCompleteTimestamp = time.Now() - metrics.RecordRequestLatencies(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) + metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.CompletionTokens) } - klog.V(logutil.VERBOSE).InfoS("Request context after HandleResponseBody", "context", reqCtx) + loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) default: - klog.V(logutil.DEFAULT).ErrorS(nil, "Unknown Request type", "request", v) + logger.V(logutil.DEFAULT).Error(nil, "Unknown Request type", "request", v) return status.Error(codes.Unknown, "unknown request type") } if err != nil { - klog.V(logutil.DEFAULT).ErrorS(err, "Failed to process request", "request", req) + logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) switch status.Code(err) { // This code can be returned by scheduler when there is no capacity for sheddable // requests. @@ -125,9 +130,9 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } - klog.V(logutil.VERBOSE).InfoS("Response generated", "response", resp) + loggerVerbose.Info("Response generated", "response", resp) if err := srv.Send(resp); err != nil { - klog.V(logutil.DEFAULT).ErrorS(err, "Send failed") + logger.V(logutil.DEFAULT).Error(err, "Send failed") return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } diff --git a/pkg/ext-proc/health.go b/pkg/ext-proc/health.go index aabb150d..8b684d39 100644 --- a/pkg/ext-proc/health.go +++ b/pkg/ext-proc/health.go @@ -3,24 +3,25 @@ package main import ( "context" + "github.com/go-logr/logr" "google.golang.org/grpc/codes" healthPb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/status" - klog "k8s.io/klog/v2" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type healthServer struct { + logger logr.Logger datastore *backend.K8sDatastore } func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) { if !s.datastore.HasSynced() { - klog.V(logutil.VERBOSE).InfoS("gRPC health check not serving", "service", in.Service) + s.logger.V(logutil.VERBOSE).Info("gRPC health check not serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil } - klog.V(logutil.VERBOSE).InfoS("gRPC health check serving", "service", in.Service) + s.logger.V(logutil.VERBOSE).Info("gRPC health check serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil } diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 8f4cd8e7..ba593d7d 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -8,6 +8,7 @@ import ( "os" "strconv" + "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus/promhttp" uberzap "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -18,7 +19,6 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/component-base/metrics/legacyregistry" - klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -79,7 +79,8 @@ var ( "are assumed to be named tls.crt and tls.key, respectively. If not set, and secureServing is enabled, "+ "then a self-signed certificate is used.") - scheme = runtime.NewScheme() + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") ) func init() { @@ -103,7 +104,7 @@ func run() error { // Validate flags if err := validateFlags(); err != nil { - klog.ErrorS(err, "Failed to validate flags") + setupLog.Error(err, "Failed to validate flags") return err } @@ -112,20 +113,20 @@ func run() error { flag.VisitAll(func(f *flag.Flag) { flags[f.Name] = f.Value }) - klog.InfoS("Flags processed", "flags", flags) + setupLog.Info("Flags processed", "flags", flags) datastore := backend.NewK8sDataStore() // Init runtime. cfg, err := ctrl.GetConfig() if err != nil { - klog.ErrorS(err, "Failed to get rest config") + setupLog.Error(err, "Failed to get rest config") return err } mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) if err != nil { - klog.ErrorS(err, "Failed to create controller manager", "config", cfg) + setupLog.Error(err, "Failed to create controller manager", "config", cfg) return err } @@ -143,18 +144,20 @@ func run() error { CertPath: *certPath, } if err := serverRunner.SetupWithManager(mgr); err != nil { - klog.ErrorS(err, "Failed to setup ext-proc server") + setupLog.Error(err, "Failed to setup ext-proc server") return err } // Register health server. - if err := registerHealthServer(mgr, datastore, *grpcHealthPort); err != nil { + if err := registerHealthServer(mgr, ctrl.Log.WithName("health"), datastore, *grpcHealthPort); err != nil { return err } // Register ext-proc server. - if err := mgr.Add(serverRunner.AsRunnable(datastore, &vllm.PodMetricsClientImpl{})); err != nil { - klog.ErrorS(err, "Failed to register ext-proc server") + if err := mgr.Add(serverRunner.AsRunnable( + ctrl.Log.WithName("ext-proc"), datastore, &vllm.PodMetricsClientImpl{}, + )); err != nil { + setupLog.Error(err, "Failed to register ext-proc server") return err } @@ -164,12 +167,12 @@ func run() error { } // Start the manager. This blocks until a signal is received. - klog.InfoS("Controller manager starting") + setupLog.Info("Controller manager starting") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - klog.ErrorS(err, "Error starting controller manager") + setupLog.Error(err, "Error starting controller manager") return err } - klog.InfoS("Controller manager terminated") + setupLog.Info("Controller manager terminated") return nil } @@ -189,16 +192,18 @@ func initLogging(opts *zap.Options) { logger := zap.New(zap.UseFlagOptions(opts), zap.RawZapOpts(uberzap.AddCaller())) ctrl.SetLogger(logger) - klog.SetLogger(logger) } // registerHealthServer adds the Health gRPC server as a Runnable to the given manager. -func registerHealthServer(mgr manager.Manager, ds *backend.K8sDatastore, port int) error { +func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds *backend.K8sDatastore, port int) error { srv := grpc.NewServer() - healthPb.RegisterHealthServer(srv, &healthServer{datastore: ds}) + healthPb.RegisterHealthServer(srv, &healthServer{ + logger: logger, + datastore: ds, + }) if err := mgr.Add( runnable.NoLeaderElection(runnable.GRPCServer("health", srv, port))); err != nil { - klog.ErrorS(err, "Failed to register health server") + setupLog.Error(err, "Failed to register health server") return err } return nil @@ -226,7 +231,7 @@ func registerMetricsHandler(mgr manager.Manager, port int, cfg *rest.Config) err Name: "metrics", Server: srv, }); err != nil { - klog.ErrorS(err, "Failed to register metrics HTTP handler") + setupLog.Error(err, "Failed to register metrics HTTP handler") return err } return nil @@ -239,19 +244,19 @@ func metricsHandlerWithAuthenticationAndAuthorization(cfg *rest.Config) (http.Ha ) httpClient, err := rest.HTTPClientFor(cfg) if err != nil { - klog.ErrorS(err, "Failed to create http client for metrics auth") + setupLog.Error(err, "Failed to create http client for metrics auth") return nil, err } filter, err := filters.WithAuthenticationAndAuthorization(cfg, httpClient) if err != nil { - klog.ErrorS(err, "Failed to create metrics filter for auth") + setupLog.Error(err, "Failed to create metrics filter for auth") return nil, err } - metricsLogger := klog.LoggerWithValues(klog.NewKlogr(), "path", defaultMetricsEndpoint) + metricsLogger := ctrl.Log.WithName("metrics").WithValues("path", defaultMetricsEndpoint) metricsAuthHandler, err := filter(metricsLogger, h) if err != nil { - klog.ErrorS(err, "Failed to create metrics auth handler") + setupLog.Error(err, "Failed to create metrics auth handler") return nil, err } return metricsAuthHandler, nil diff --git a/pkg/ext-proc/metrics/metrics.go b/pkg/ext-proc/metrics/metrics.go index 1412af6e..e3226f47 100644 --- a/pkg/ext-proc/metrics/metrics.go +++ b/pkg/ext-proc/metrics/metrics.go @@ -1,12 +1,13 @@ package metrics import ( + "context" "sync" "time" compbasemetrics "k8s.io/component-base/metrics" "k8s.io/component-base/metrics/legacyregistry" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -144,9 +145,9 @@ func RecordRequestSizes(modelName, targetModelName string, reqSize int) { } // RecordRequestLatencies records duration of request. -func RecordRequestLatencies(modelName, targetModelName string, received time.Time, complete time.Time) bool { +func RecordRequestLatencies(ctx context.Context, modelName, targetModelName string, received time.Time, complete time.Time) bool { if !complete.After(received) { - klog.V(logutil.DEFAULT).ErrorS(nil, "Request latency values are invalid", + log.FromContext(ctx).V(logutil.DEFAULT).Error(nil, "Request latency values are invalid", "modelName", modelName, "targetModelName", targetModelName, "completeTime", complete, "receivedTime", received) return false } diff --git a/pkg/ext-proc/metrics/metrics_test.go b/pkg/ext-proc/metrics/metrics_test.go index 348f707e..d24afdb1 100644 --- a/pkg/ext-proc/metrics/metrics_test.go +++ b/pkg/ext-proc/metrics/metrics_test.go @@ -1,22 +1,26 @@ package metrics import ( + "context" "os" "testing" "time" "k8s.io/component-base/metrics/legacyregistry" "k8s.io/component-base/metrics/testutil" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) -const RequestTotalMetric = InferenceModelComponent + "_request_total" -const RequestLatenciesMetric = InferenceModelComponent + "_request_duration_seconds" -const RequestSizesMetric = InferenceModelComponent + "_request_sizes" -const ResponseSizesMetric = InferenceModelComponent + "_response_sizes" -const InputTokensMetric = InferenceModelComponent + "_input_tokens" -const OutputTokensMetric = InferenceModelComponent + "_output_tokens" -const KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" -const QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" +const ( + RequestTotalMetric = InferenceModelComponent + "_request_total" + RequestLatenciesMetric = InferenceModelComponent + "_request_duration_seconds" + RequestSizesMetric = InferenceModelComponent + "_request_sizes" + ResponseSizesMetric = InferenceModelComponent + "_response_sizes" + InputTokensMetric = InferenceModelComponent + "_input_tokens" + OutputTokensMetric = InferenceModelComponent + "_output_tokens" + KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" + QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" +) func TestRecordRequestCounterandSizes(t *testing.T) { type requests struct { @@ -83,12 +87,12 @@ func TestRecordRequestCounterandSizes(t *testing.T) { if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRequestSizes, RequestSizesMetric); err != nil { t.Error(err) } - }) } } func TestRecordRequestLatencies(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) timeBaseline := time.Now() type requests struct { modelName string @@ -100,35 +104,36 @@ func TestRecordRequestLatencies(t *testing.T) { name string reqs []requests invalid bool - }{{ - name: "multiple requests", - reqs: []requests{ - { - modelName: "m10", - targetModelName: "t10", - receivedTime: timeBaseline, - completeTime: timeBaseline.Add(time.Millisecond * 10), - }, - { - modelName: "m10", - targetModelName: "t10", - receivedTime: timeBaseline, - completeTime: timeBaseline.Add(time.Millisecond * 1600), - }, - { - modelName: "m10", - targetModelName: "t11", - receivedTime: timeBaseline, - completeTime: timeBaseline.Add(time.Millisecond * 60), - }, - { - modelName: "m20", - targetModelName: "t20", - receivedTime: timeBaseline, - completeTime: timeBaseline.Add(time.Millisecond * 120), + }{ + { + name: "multiple requests", + reqs: []requests{ + { + modelName: "m10", + targetModelName: "t10", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 10), + }, + { + modelName: "m10", + targetModelName: "t10", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 1600), + }, + { + modelName: "m10", + targetModelName: "t11", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 60), + }, + { + modelName: "m20", + targetModelName: "t20", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 120), + }, }, }, - }, { name: "invalid elapsed time", reqs: []requests{ @@ -137,14 +142,16 @@ func TestRecordRequestLatencies(t *testing.T) { targetModelName: "t10", receivedTime: timeBaseline.Add(time.Millisecond * 10), completeTime: timeBaseline, - }}, + }, + }, invalid: true, - }} + }, + } Register() for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { for _, req := range scenario.reqs { - success := RecordRequestLatencies(req.modelName, req.targetModelName, req.receivedTime, req.completeTime) + success := RecordRequestLatencies(ctx, req.modelName, req.targetModelName, req.receivedTime, req.completeTime) if success == scenario.invalid { t.Errorf("got record success(%v), but the request expects invalid(%v)", success, scenario.invalid) } @@ -277,7 +284,6 @@ func TestInferencePoolMetrics(t *testing.T) { Register() for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { - RecordInferencePoolAvgKVCache(scenario.poolName, scenario.kvCacheAvg) RecordInferencePoolAvgQueueSize(scenario.poolName, scenario.queueSizeAvg) diff --git a/pkg/ext-proc/scheduling/filter.go b/pkg/ext-proc/scheduling/filter.go index ac7a287c..e028c59a 100644 --- a/pkg/ext-proc/scheduling/filter.go +++ b/pkg/ext-proc/scheduling/filter.go @@ -4,14 +4,14 @@ import ( "errors" "math" - klog "k8s.io/klog/v2" + "github.com/go-logr/logr" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type Filter interface { Name() string - Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) + Filter(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) } // filter applies current filterFunc, and then recursively applies next filters depending success or @@ -41,10 +41,11 @@ func (f *filter) Name() string { return f.name } -func (f *filter) Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { - klog.V(logutil.VERBOSE).InfoS("Running a filter", "name", f.Name(), "request", req, "podCount", len(pods)) +func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { + loggerTrace := logger.V(logutil.TRACE) + loggerTrace.Info("Running a filter", "name", f.Name(), "podCount", len(pods)) - filtered, err := f.filter(req, pods) + filtered, err := f.filter(logger, req, pods) next := f.nextOnSuccessOrFailure if err == nil && len(filtered) > 0 { @@ -55,9 +56,9 @@ func (f *filter) Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend if f.nextOnSuccess != nil { next = f.nextOnSuccess } - klog.V(logutil.VERBOSE).InfoS("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filtered)) + loggerTrace.Info("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filtered)) // On success, pass the filtered result to the next filter. - return next.Filter(req, filtered) + return next.Filter(logger, req, filtered) } else { if f.nextOnFailure == nil && f.nextOnSuccessOrFailure == nil { // No succeeding filters to run, return. @@ -66,18 +67,18 @@ func (f *filter) Filter(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend if f.nextOnFailure != nil { next = f.nextOnFailure } - klog.V(logutil.VERBOSE).InfoS("Filter failed", "filter", f.Name(), "next", next.Name()) + loggerTrace.Info("Filter failed", "filter", f.Name(), "next", next.Name()) // On failure, pass the initial set of pods to the next filter. - return next.Filter(req, pods) + return next.Filter(logger, req, pods) } } // filterFunc filters a set of input pods to a subset. -type filterFunc func(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) +type filterFunc func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) // toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. func toFilterFunc(pp podPredicate) filterFunc { - return func(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { + return func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { filtered := []*backend.PodMetrics{} for _, pod := range pods { pass := pp(req, pod) @@ -99,7 +100,7 @@ func toFilterFunc(pp podPredicate) filterFunc { // the least one as it gives more choices for the next filter, which on aggregate gave better // results. // TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { +func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { min := math.MaxInt max := 0 filtered := []*backend.PodMetrics{} @@ -131,7 +132,7 @@ func lowQueueingPodPredicate(_ *LLMRequest, pod *backend.PodMetrics) bool { // should consider them all instead of the absolute minimum one. This worked better than picking the // least one as it gives more choices for the next filter, which on aggregate gave better results. // TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { +func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { min := math.MaxFloat64 var max float64 = 0 filtered := []*backend.PodMetrics{} diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/ext-proc/scheduling/filter_test.go index 224dc83f..ee1a8c33 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/ext-proc/scheduling/filter_test.go @@ -4,11 +4,15 @@ import ( "errors" "testing" + "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func TestFilter(t *testing.T) { + logger := logutil.NewTestLogger() + tests := []struct { name string req *LLMRequest @@ -19,7 +23,7 @@ func TestFilter(t *testing.T) { }{ { name: "simple filter without successor, failure", - filter: &filter{filter: func(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { + filter: &filter{filter: func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { return nil, errors.New("filter error") }}, err: true, @@ -201,7 +205,7 @@ func TestFilter(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.filter.Filter(test.req, test.input) + got, err := test.filter.Filter(logger, test.req, test.input) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } @@ -214,6 +218,8 @@ func TestFilter(t *testing.T) { } func TestFilterFunc(t *testing.T) { + logger := logutil.NewTestLogger() + tests := []struct { name string f filterFunc @@ -395,7 +401,7 @@ func TestFilterFunc(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.f(test.req, test.input) + got, err := test.f(logger, test.req, test.input) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index 50564898..16cf90b8 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -2,12 +2,14 @@ package scheduling import ( + "context" "fmt" "math/rand" + "github.com/go-logr/logr" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -82,8 +84,8 @@ var ( // request to make room for critical requests. nextOnFailure: &filter{ name: "drop request", - filter: func(req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { - klog.V(logutil.DEFAULT).InfoS("Request dropped", "request", req) + filter: func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { + logger.V(logutil.DEFAULT).Info("Request dropped", "request", req) return []*backend.PodMetrics{}, status.Errorf( codes.ResourceExhausted, "dropping request due to limited backend resources") }, @@ -110,14 +112,15 @@ type PodMetricsProvider interface { } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(req *LLMRequest) (targetPod backend.Pod, err error) { - klog.V(logutil.VERBOSE).InfoS("Scheduling a request", "request", req, "metrics", s.podMetricsProvider.AllPodMetrics()) - pods, err := s.filter.Filter(req, s.podMetricsProvider.AllPodMetrics()) +func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backend.Pod, err error) { + logger := log.FromContext(ctx).WithValues("request", req) + logger.V(logutil.VERBOSE).Info("Scheduling a request", "metrics", s.podMetricsProvider.AllPodMetrics()) + pods, err := s.filter.Filter(logger, req, s.podMetricsProvider.AllPodMetrics()) if err != nil || len(pods) == 0 { return backend.Pod{}, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } - klog.V(logutil.VERBOSE).InfoS("Selecting a random pod from the candidates", "candidatePods", pods) + logger.V(logutil.VERBOSE).Info("Selecting a random pod from the candidates", "candidatePods", pods) i := rand.Intn(len(pods)) return pods[i].Pod, nil } diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index ed260b04..fb9741d2 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -13,10 +13,10 @@ import ( "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "k8s.io/apimachinery/pkg/types" - klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" @@ -108,14 +108,15 @@ func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { // AsRunnable returns a Runnable that can be used to start the ext-proc gRPC server. // The runnable implements LeaderElectionRunnable with leader election disabled. func (r *ExtProcServerRunner) AsRunnable( + logger logr.Logger, podDatastore *backend.K8sDatastore, podMetricsClient backend.PodMetricsClient, ) manager.Runnable { return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { // Initialize backend provider pp := backend.NewProvider(podMetricsClient, podDatastore) - if err := pp.Init(r.RefreshPodsInterval, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { - klog.ErrorS(err, "Failed to initialize backend provider") + if err := pp.Init(logger.WithName("provider"), r.RefreshPodsInterval, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { + logger.Error(err, "Failed to initialize backend provider") return err } @@ -127,10 +128,10 @@ func (r *ExtProcServerRunner) AsRunnable( cert, err = tls.LoadX509KeyPair(r.CertPath+"/tls.crt", r.CertPath+"/tls.key") } else { // Create tls based credential. - cert, err = createSelfSignedTLSCertificate() + cert, err = createSelfSignedTLSCertificate(logger) } if err != nil { - klog.ErrorS(err, "Failed to create self signed certificate") + logger.Error(err, "Failed to create self signed certificate") return err } @@ -152,11 +153,11 @@ func (r *ExtProcServerRunner) AsRunnable( })) } -func createSelfSignedTLSCertificate() (tls.Certificate, error) { +func createSelfSignedTLSCertificate(logger logr.Logger) (tls.Certificate, error) { serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { - klog.ErrorS(err, "Failed to create serial number for self-signed cert") + logger.Error(err, "Failed to create serial number for self-signed cert") return tls.Certificate{}, err } now := time.Now() @@ -175,13 +176,13 @@ func createSelfSignedTLSCertificate() (tls.Certificate, error) { priv, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { - klog.ErrorS(err, "Failed to generate key for self-signed cert") + logger.Error(err, "Failed to generate key for self-signed cert") return tls.Certificate{}, err } derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) if err != nil { - klog.ErrorS(err, "Failed to create self-signed certificate") + logger.Error(err, "Failed to create self-signed certificate") return tls.Certificate{}, err } @@ -189,7 +190,7 @@ func createSelfSignedTLSCertificate() (tls.Certificate, error) { privBytes, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { - klog.ErrorS(err, "Failed to marshal private key for self-signed certificate") + logger.Error(err, "Failed to marshal private key for self-signed certificate") return tls.Certificate{}, err } keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) diff --git a/pkg/ext-proc/server/runserver_test.go b/pkg/ext-proc/server/runserver_test.go index df2081aa..1badb8fd 100644 --- a/pkg/ext-proc/server/runserver_test.go +++ b/pkg/ext-proc/server/runserver_test.go @@ -6,11 +6,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) func TestRunnable(t *testing.T) { // Make sure AsRunnable() does not use leader election. - runner := server.NewDefaultExtProcServerRunner().AsRunnable(nil, nil) + runner := server.NewDefaultExtProcServerRunner().AsRunnable(logutil.NewTestLogger(), nil, nil) r, ok := runner.(manager.LeaderElectionRunnable) if !ok { t.Fatal("runner is not LeaderElectionRunnable") diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index c83dbcb9..9eca2edc 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -8,9 +8,11 @@ import ( "github.com/bojand/ghz/printer" "github.com/bojand/ghz/runner" + "github.com/go-logr/logr" "github.com/jhump/protoreflect/desc" + uberzap "go.uber.org/zap" "google.golang.org/protobuf/proto" - klog "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" @@ -41,24 +43,29 @@ func main() { } func run() error { - klog.InitFlags(nil) + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) flag.Parse() + logger := zap.New(zap.UseFlagOptions(&opts), zap.RawZapOpts(uberzap.AddCaller())) + if *localServer { - test.StartExtProc(port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) + test.StartExtProc(logger, port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) time.Sleep(time.Second) // wait until server is up - klog.InfoS("Server started") + logger.Info("Server started") } report, err := runner.Run( "envoy.service.ext_proc.v3.ExternalProcessor.Process", *svrAddr, runner.WithInsecure(true), - runner.WithBinaryDataFunc(generateRequest), + runner.WithBinaryDataFunc(generateRequestFunc(logger)), runner.WithTotalRequests(uint(*totalRequests)), ) if err != nil { - klog.ErrorS(err, "Runner failed") + logger.Error(err, "Runner failed") return err } @@ -71,14 +78,16 @@ func run() error { return nil } -func generateRequest(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { - numModels := *numFakePods * (*numModelsPerPod) - req := test.GenerateRequest(modelName(int(callData.RequestNumber) % numModels)) - data, err := proto.Marshal(req) - if err != nil { - logutil.Fatal(err, "Failed to marshal request", "request", req) +func generateRequestFunc(logger logr.Logger) func(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { + return func(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { + numModels := *numFakePods * (*numModelsPerPod) + req := test.GenerateRequest(logger, modelName(int(callData.RequestNumber)%numModels)) + data, err := proto.Marshal(req) + if err != nil { + logutil.Fatal(logger, err, "Failed to marshal request", "request", req) + } + return data } - return data } func fakeModels() map[string]*v1alpha1.InferenceModel { diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index 4c000722..cb99a36b 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -7,9 +7,9 @@ import ( "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" "google.golang.org/grpc" "google.golang.org/grpc/reflection" - klog "k8s.io/klog/v2" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" @@ -17,7 +17,13 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) -func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel) *grpc.Server { +func StartExtProc( + logger logr.Logger, + port int, + refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, + pods []*backend.PodMetrics, + models map[string]*v1alpha1.InferenceModel, +) *grpc.Server { ps := make(backend.PodSet) pms := make(map[backend.Pod]*backend.PodMetrics) for _, pod := range pods { @@ -26,35 +32,35 @@ func StartExtProc(port int, refreshPodsInterval, refreshMetricsInterval, refresh } pmc := &backend.FakePodMetricsClient{Res: pms} pp := backend.NewProvider(pmc, backend.NewK8sDataStore(backend.WithPods(pods))) - if err := pp.Init(refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { - logutil.Fatal(err, "Failed to initialize") + if err := pp.Init(logger, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { + logutil.Fatal(logger, err, "Failed to initialize") } - return startExtProc(port, pp, models) + return startExtProc(logger, port, pp, models) } // startExtProc starts an extProc server with fake pods. -func startExtProc(port int, pp *backend.Provider, models map[string]*v1alpha1.InferenceModel) *grpc.Server { +func startExtProc(logger logr.Logger, port int, pp *backend.Provider, models map[string]*v1alpha1.InferenceModel) *grpc.Server { lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { - logutil.Fatal(err, "Failed to listen", "port", port) + logutil.Fatal(logger, err, "Failed to listen", "port", port) } s := grpc.NewServer() extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(pp, scheduling.NewScheduler(pp), "target-pod", &backend.FakeDataStore{Res: models})) - klog.InfoS("gRPC server starting", "port", port) + logger.Info("gRPC server starting", "port", port) reflection.Register(s) go func() { err := s.Serve(lis) if err != nil { - logutil.Fatal(err, "Ext-proc failed with the err") + logutil.Fatal(logger, err, "Ext-proc failed with the err") } }() return s } -func GenerateRequest(model string) *extProcPb.ProcessingRequest { +func GenerateRequest(logger logr.Logger, model string) *extProcPb.ProcessingRequest { j := map[string]interface{}{ "model": model, "prompt": "hello", @@ -64,7 +70,7 @@ func GenerateRequest(model string) *extProcPb.ProcessingRequest { llmReq, err := json.Marshal(j) if err != nil { - logutil.Fatal(err, "Failed to unmarshal LLM request") + logutil.Fatal(logger, err, "Failed to unmarshal LLM request") } req := &extProcPb.ProcessingRequest{ Request: &extProcPb.ProcessingRequest_RequestBody{ diff --git a/pkg/ext-proc/util/logging/fatal.go b/pkg/ext-proc/util/logging/fatal.go index 65926824..1f85b450 100644 --- a/pkg/ext-proc/util/logging/fatal.go +++ b/pkg/ext-proc/util/logging/fatal.go @@ -1,11 +1,15 @@ package logging -import "k8s.io/klog/v2" +import ( + "os" -// Fatal calls klog.ErrorS followed by klog.FlushAndExit(1). + "github.com/go-logr/logr" +) + +// Fatal calls logger.Error followed by os.Exit(1). // // This is a utility function and should not be used in production code! -func Fatal(err error, msg string, keysAndValues ...interface{}) { - klog.ErrorS(err, msg, keysAndValues...) - klog.FlushAndExit(klog.ExitFlushTimeout, 1) +func Fatal(logger logr.Logger, err error, msg string, keysAndValues ...interface{}) { + logger.Error(err, msg, keysAndValues...) + os.Exit(1) } diff --git a/pkg/ext-proc/util/logging/logger.go b/pkg/ext-proc/util/logging/logger.go new file mode 100644 index 00000000..086a012f --- /dev/null +++ b/pkg/ext-proc/util/logging/logger.go @@ -0,0 +1,20 @@ +package logging + +import ( + "context" + + "github.com/go-logr/logr" + uberzap "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// NewTestLogger creates a new Zap logger using the dev mode. +func NewTestLogger() logr.Logger { + return zap.New(zap.UseDevMode(true), zap.RawZapOpts(uberzap.AddCaller())) +} + +// NewTestLoggerIntoContext creates a new Zap logger using the dev mode and inserts it into the given context. +func NewTestLoggerIntoContext(ctx context.Context) context.Context { + return log.IntoContext(ctx, zap.New(zap.UseDevMode(true), zap.RawZapOpts(uberzap.AddCaller()))) +} diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 6424663b..a99b6bd7 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -6,7 +6,6 @@ import ( "bytes" "context" "errors" - "flag" "fmt" "io" "os" @@ -26,7 +25,6 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8syaml "k8s.io/apimachinery/pkg/util/yaml" clientgoscheme "k8s.io/client-go/kubernetes/scheme" - klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" @@ -47,6 +45,7 @@ var ( k8sClient k8sclient.Client testEnv *envtest.Environment scheme = runtime.NewScheme() + logger = logutil.NewTestLogger().V(logutil.VERBOSE) ) func TestKubeInferenceModelRequest(t *testing.T) { @@ -62,7 +61,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }{ { name: "select lower queue and kv cache, no active lora", - req: extprocutils.GenerateRequest("my-model"), + req: extprocutils.GenerateRequest(logger, "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. pods: []*backend.PodMetrics{ { @@ -115,7 +114,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "select active lora, low queue", - req: extprocutils.GenerateRequest("sql-lora"), + req: extprocutils.GenerateRequest(logger, "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. pods: []*backend.PodMetrics{ @@ -180,7 +179,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "select no lora despite active model, avoid excessive queue size", - req: extprocutils.GenerateRequest("sql-lora"), + req: extprocutils.GenerateRequest(logger, "sql-lora"), // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 @@ -246,7 +245,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical and all models past threshold, shed request", - req: extprocutils.GenerateRequest("sql-lora-sheddable"), + req: extprocutils.GenerateRequest(logger, "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. pods: []*backend.PodMetrics{ @@ -297,7 +296,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical, but one server has capacity, do not shed", - req: extprocutils.GenerateRequest("sql-lora-sheddable"), + req: extprocutils.GenerateRequest(logger, "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold pods: []*backend.PodMetrics{ { @@ -418,9 +417,9 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP serverCtx, stopServer := context.WithCancel(context.Background()) go func() { if err := serverRunner.AsRunnable( - backend.NewK8sDataStore(backend.WithPods(pods)), pmc, + logger.WithName("ext-proc"), backend.NewK8sDataStore(backend.WithPods(pods)), pmc, ).Start(serverCtx); err != nil { - logutil.Fatal(err, "Failed to start ext-proc server") + logutil.Fatal(logger, err, "Failed to start ext-proc server") } }() @@ -431,13 +430,13 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP // Create a grpc connection conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - logutil.Fatal(err, "Failed to connect", "address", address) + logutil.Fatal(logger, err, "Failed to connect", "address", address) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) if err != nil { - logutil.Fatal(err, "Failed to create client") + logutil.Fatal(logger, err, "Failed to create client") } return client, func() { cancel() @@ -455,7 +454,7 @@ func BeforeSuit() { } cfg, err := testEnv.Start() if err != nil { - logutil.Fatal(err, "Failed to start test environment", "config", cfg) + logutil.Fatal(logger, err, "Failed to start test environment", "config", cfg) } utilruntime.Must(clientgoscheme.AddToScheme(scheme)) @@ -463,15 +462,16 @@ func BeforeSuit() { k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) if err != nil { - logutil.Fatal(err, "Failed to start k8s Client") + logutil.Fatal(logger, err, "Failed to start k8s Client") } else if k8sClient == nil { - logutil.Fatal(nil, "No error, but returned kubernetes client is nil", "config", cfg) + logutil.Fatal(logger, nil, "No error, but returned kubernetes client is nil", "config", cfg) } // Init runtime. + ctrl.SetLogger(logger) mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) if err != nil { - logutil.Fatal(err, "Failed to create controller manager") + logutil.Fatal(logger, err, "Failed to create controller manager") } serverRunner = runserver.NewDefaultExtProcServerRunner() @@ -481,50 +481,46 @@ func BeforeSuit() { serverRunner.SecureServing = false if err := serverRunner.SetupWithManager(mgr); err != nil { - logutil.Fatal(err, "Failed to setup server runner") + logutil.Fatal(logger, err, "Failed to setup server runner") } // Start the controller manager in go routine, not blocking go func() { if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - logutil.Fatal(err, "Failed to start manager") + logutil.Fatal(logger, err, "Failed to start manager") } }() - klog.InfoS("Setting up hermetic ExtProc server") - klog.InitFlags(nil) - flag.Parse() - // Configure klog verbosity levels to print ext proc logs. - _ = flag.Lookup("v").Value.Set("3") + logger.Info("Setting up hermetic ExtProc server") // Unmarshal CRDs from file into structs manifestsPath := filepath.Join("..", "testdata", "inferencepool-with-model-hermetic.yaml") docs, err := readDocuments(manifestsPath) if err != nil { - logutil.Fatal(err, "Can't read object manifests", "path", manifestsPath) + logutil.Fatal(logger, err, "Can't read object manifests", "path", manifestsPath) } for _, doc := range docs { inferenceModel := &v1alpha1.InferenceModel{} if err = yaml.Unmarshal(doc, inferenceModel); err != nil { - logutil.Fatal(err, "Can't unmarshal object", "document", doc) + logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) } if inferenceModel.Kind == "InferenceModel" { - klog.InfoS("Creating inference model", "model", inferenceModel) + logger.Info("Creating inference model", "model", inferenceModel) if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { - logutil.Fatal(err, "Unable to create inferenceModel", "modelName", inferenceModel.Name) + logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", inferenceModel.Name) } } } for _, doc := range docs { inferencePool := &v1alpha1.InferencePool{} if err = yaml.Unmarshal(doc, inferencePool); err != nil { - logutil.Fatal(err, "Can't unmarshal object", "document", doc) + logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) } if inferencePool.Kind == "InferencePool" { - klog.InfoS("Creating inference pool", "pool", inferencePool) + logger.Info("Creating inference pool", "pool", inferencePool) if err := k8sClient.Create(context.Background(), inferencePool); err != nil { - logutil.Fatal(err, "Unable to create inferencePool", "poolName", inferencePool.Name) + logutil.Fatal(logger, err, "Unable to create inferencePool", "poolName", inferencePool.Name) } } } From bc5eac67bd3f70bd87210a660b8f6edd5bc2e6f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2025 15:48:12 -0800 Subject: [PATCH 027/260] Bump the kubernetes group with 6 updates (#351) Bumps the kubernetes group with 6 updates: | Package | From | To | | --- | --- | --- | | [k8s.io/api](https://github.com/kubernetes/api) | `0.32.1` | `0.32.2` | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.32.1` | `0.32.2` | | [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `0.32.1` | `0.32.2` | | [k8s.io/client-go](https://github.com/kubernetes/client-go) | `0.32.1` | `0.32.2` | | [k8s.io/code-generator](https://github.com/kubernetes/code-generator) | `0.32.1` | `0.32.2` | | [k8s.io/component-base](https://github.com/kubernetes/component-base) | `0.32.1` | `0.32.2` | Updates `k8s.io/api` from 0.32.1 to 0.32.2 - [Commits](https://github.com/kubernetes/api/compare/v0.32.1...v0.32.2) Updates `k8s.io/apiextensions-apiserver` from 0.32.1 to 0.32.2 - [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases) - [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.32.1...v0.32.2) Updates `k8s.io/apimachinery` from 0.32.1 to 0.32.2 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.32.1...v0.32.2) Updates `k8s.io/client-go` from 0.32.1 to 0.32.2 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.32.1...v0.32.2) Updates `k8s.io/code-generator` from 0.32.1 to 0.32.2 - [Commits](https://github.com/kubernetes/code-generator/compare/v0.32.1...v0.32.2) Updates `k8s.io/component-base` from 0.32.1 to 0.32.2 - [Commits](https://github.com/kubernetes/component-base/compare/v0.32.1...v0.32.2) --- updated-dependencies: - dependency-name: k8s.io/api dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apiextensions-apiserver dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apimachinery dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/client-go dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/code-generator dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/component-base dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index a59a28cc..2c590489 100644 --- a/go.mod +++ b/go.mod @@ -21,12 +21,12 @@ require ( go.uber.org/zap v1.27.0 google.golang.org/grpc v1.70.0 google.golang.org/protobuf v1.36.5 - k8s.io/api v0.32.1 - k8s.io/apiextensions-apiserver v0.32.1 - k8s.io/apimachinery v0.32.1 - k8s.io/client-go v0.32.1 - k8s.io/code-generator v0.32.1 - k8s.io/component-base v0.32.1 + k8s.io/api v0.32.2 + k8s.io/apiextensions-apiserver v0.32.2 + k8s.io/apimachinery v0.32.2 + k8s.io/client-go v0.32.2 + k8s.io/code-generator v0.32.2 + k8s.io/component-base v0.32.2 k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.1 @@ -135,7 +135,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.32.1 // indirect + k8s.io/apiserver v0.32.2 // indirect k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect diff --git a/go.sum b/go.sum index 803ed988..f10f9a31 100644 --- a/go.sum +++ b/go.sum @@ -347,20 +347,20 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.32.1 h1:f562zw9cy+GvXzXf0CKlVQ7yHJVYzLfL6JAS4kOAaOc= -k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k= -k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw= -k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto= -k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs= -k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/apiserver v0.32.1 h1:oo0OozRos66WFq87Zc5tclUX2r0mymoVHRq8JmR7Aak= -k8s.io/apiserver v0.32.1/go.mod h1:UcB9tWjBY7aryeI5zAgzVJB/6k7E97bkr1RgqDz0jPw= -k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU= -k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg= -k8s.io/code-generator v0.32.1 h1:4lw1kFNDuFYXquTkB7Sl5EwPMUP2yyW9hh6BnFfRZFY= -k8s.io/code-generator v0.32.1/go.mod h1:zaILfm00CVyP/6/pJMJ3zxRepXkxyDfUV5SNG4CjZI4= -k8s.io/component-base v0.32.1 h1:/5IfJ0dHIKBWysGV0yKTFfacZ5yNV1sulPh3ilJjRZk= -k8s.io/component-base v0.32.1/go.mod h1:j1iMMHi/sqAHeG5z+O9BFNCF698a1u0186zkjMZQ28w= +k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= +k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= +k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= +k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= +k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= +k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apiserver v0.32.2 h1:WzyxAu4mvLkQxwD9hGa4ZfExo3yZZaYzoYvvVDlM6vw= +k8s.io/apiserver v0.32.2/go.mod h1:PEwREHiHNU2oFdte7BjzA1ZyjWjuckORLIK/wLV5goM= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +k8s.io/code-generator v0.32.2 h1:CIvyPrLWP7cMgrqval2qYT839YAwCDeSvGfXgWSNpHQ= +k8s.io/code-generator v0.32.2/go.mod h1:plh7bWk7JztAUkHM4zpbdy0KOMdrhsePcZL2HLWFH7Y= +k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU= +k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkvvIeH8UqffFJDl0bz4= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= From 5c67d108b4e758b9cfa20f17a879954dd43704d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Feb 2025 08:14:27 -0800 Subject: [PATCH 028/260] Bump sigs.k8s.io/controller-runtime from 0.20.1 to 0.20.2 (#352) Bumps [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) from 0.20.1 to 0.20.2. - [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases) - [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.20.1...v0.20.2) --- updated-dependencies: - dependency-name: sigs.k8s.io/controller-runtime dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2c590489..25daf027 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( k8s.io/component-base v0.32.2 k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20241210054802-24370beab758 - sigs.k8s.io/controller-runtime v0.20.1 + sigs.k8s.io/controller-runtime v0.20.2 sigs.k8s.io/structured-merge-diff/v4 v4.5.0 sigs.k8s.io/yaml v1.4.0 ) @@ -57,7 +57,7 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect diff --git a/go.sum b/go.sum index f10f9a31..2d54aba2 100644 --- a/go.sum +++ b/go.sum @@ -59,8 +59,8 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= -github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -371,8 +371,8 @@ k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJ k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.20.1 h1:JbGMAG/X94NeM3xvjenVUaBjy6Ui4Ogd/J5ZtjZnHaE= -sigs.k8s.io/controller-runtime v0.20.1/go.mod h1:BrP3w158MwvB3ZbNpaAcIKkHQ7YGpYnzpoSTZ8E14WU= +sigs.k8s.io/controller-runtime v0.20.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= +sigs.k8s.io/controller-runtime v0.20.2/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A= sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= From 755b81bfd70fbc6cb3343ab6e33ce3c7d33c0f49 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Tue, 18 Feb 2025 17:22:28 +0000 Subject: [PATCH 029/260] Fixes to the adapter rollouts guide (#338) * Polishing to the adapter rollouts guide * Make all guides use the same deployment so that we can till one story as the user navigates through the guides * Addressed comments --- mkdocs.yml | 1 + pkg/manifests/inferencemodel.yaml | 11 +- .../vllm/deployment-with-syncer.yaml | 145 ------------------ pkg/manifests/vllm/deployment.yaml | 49 ++++-- site-src/guides/adapter-rollout.md | 133 ++++++++++++++++ site-src/guides/dynamic-lora.md | 93 ----------- site-src/guides/index.md | 33 ++-- 7 files changed, 187 insertions(+), 278 deletions(-) delete mode 100644 pkg/manifests/vllm/deployment-with-syncer.yaml create mode 100644 site-src/guides/adapter-rollout.md delete mode 100644 site-src/guides/dynamic-lora.md diff --git a/mkdocs.yml b/mkdocs.yml index c9bc30e0..a024c16d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ nav: - Guides: - User Guides: - Getting started: guides/index.md + - Adapter Rollout: guides/adapter-rollout.md - Implementer's Guide: guides/implementers.md - Reference: - API Reference: reference/spec.md diff --git a/pkg/manifests/inferencemodel.yaml b/pkg/manifests/inferencemodel.yaml index 0085a89d..2a292c16 100644 --- a/pkg/manifests/inferencemodel.yaml +++ b/pkg/manifests/inferencemodel.yaml @@ -1,21 +1,12 @@ apiVersion: inference.networking.x-k8s.io/v1alpha1 kind: InferenceModel metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize name: inferencemodel-sample spec: modelName: tweet-summary criticality: Critical poolRef: - # this is the default val: - group: inference.networking.x-k8s.io - # this is the default val: - kind: InferencePool name: vllm-llama2-7b-pool targetModels: - - name: tweet-summary-0 - weight: 50 - name: tweet-summary-1 - weight: 50 + weight: 100 diff --git a/pkg/manifests/vllm/deployment-with-syncer.yaml b/pkg/manifests/vllm/deployment-with-syncer.yaml deleted file mode 100644 index d6110f4b..00000000 --- a/pkg/manifests/vllm/deployment-with-syncer.yaml +++ /dev/null @@ -1,145 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: vllm-llama2-7b-pool -spec: - selector: - app: vllm-llama2-7b-pool - ports: - - protocol: TCP - port: 8000 - targetPort: 8000 - type: ClusterIP ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: vllm-llama2-7b-pool -spec: - replicas: 3 - selector: - matchLabels: - app: vllm-llama2-7b-pool - template: - metadata: - labels: - app: vllm-llama2-7b-pool - spec: - containers: - - name: lora - image: "vllm/vllm-openai:latest" - imagePullPolicy: Always - command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] - args: - - "--model" - - "meta-llama/Llama-2-7b-hf" - - "--tensor-parallel-size" - - "1" - - "--port" - - "8000" - - "--enable-lora" - - "--max-loras" - - "4" - - "--max-cpu-loras" - - "12" - - "--lora-modules" - - '{"name": "tweet-summary-0", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' - - '{"name": "tweet-summary-1", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' - env: - - name: PORT - value: "8000" - - name: HUGGING_FACE_HUB_TOKEN - valueFrom: - secretKeyRef: - name: hf-token - key: token - - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING - value: "true" - ports: - - containerPort: 8000 - name: http - protocol: TCP - livenessProbe: - failureThreshold: 240 - httpGet: - path: /health - port: http - scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - timeoutSeconds: 1 - readinessProbe: - failureThreshold: 600 - httpGet: - path: /health - port: http - scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 5 - successThreshold: 1 - timeoutSeconds: 1 - resources: - limits: - nvidia.com/gpu: 1 - requests: - nvidia.com/gpu: 1 - volumeMounts: - - mountPath: /data - name: data - - mountPath: /dev/shm - name: shm - - name: adapters - mountPath: "/adapters" - initContainers: - - name: lora-adapter-syncer - tty: true - stdin: true - image: us-central1-docker.pkg.dev/ahg-gke-dev/jobset2/lora-syncer:6dc97be - restartPolicy: Always - imagePullPolicy: Always - env: - - name: DYNAMIC_LORA_ROLLOUT_CONFIG - value: "/config/configmap.yaml" - volumeMounts: # DO NOT USE subPath - - name: config-volume - mountPath: /config - restartPolicy: Always - schedulerName: default-scheduler - terminationGracePeriodSeconds: 30 - volumes: - - name: data - emptyDir: {} - - name: shm - emptyDir: - medium: Memory - - name: adapters - emptyDir: {} - - name: config-volume - configMap: - name: dynamic-lora-config - ---- - -apiVersion: v1 -kind: ConfigMap -metadata: - name: dynamic-lora-config -data: - configmap.yaml: | - vLLMLoRAConfig: - name: sql-loras-llama - port: 8000 - ensureExist: - models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-0 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - ensureNotExist: - models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-2 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm \ No newline at end of file diff --git a/pkg/manifests/vllm/deployment.yaml b/pkg/manifests/vllm/deployment.yaml index 1d115f4d..a54d99b3 100644 --- a/pkg/manifests/vllm/deployment.yaml +++ b/pkg/manifests/vllm/deployment.yaml @@ -1,16 +1,3 @@ -apiVersion: v1 -kind: Service -metadata: - name: vllm-llama2-7b-pool -spec: - selector: - app: vllm-llama2-7b-pool - ports: - - protocol: TCP - port: 8000 - targetPort: 8000 - type: ClusterIP ---- apiVersion: apps/v1 kind: Deployment metadata: @@ -39,7 +26,7 @@ spec: - "8000" - "--enable-lora" - "--max-loras" - - "4" + - "2" - "--max-cpu-loras" - "12" - "--lora-modules" @@ -53,6 +40,8 @@ spec: secretKeyRef: name: hf-token key: token + - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING + value: "true" ports: - containerPort: 8000 name: http @@ -89,6 +78,19 @@ spec: name: shm - name: adapters mountPath: "/adapters" + initContainers: + - name: lora-adapter-syncer + tty: true + stdin: true + image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/lora-syncer:main + restartPolicy: Always + imagePullPolicy: Always + env: + - name: DYNAMIC_LORA_ROLLOUT_CONFIG + value: "/config/configmap.yaml" + volumeMounts: # DO NOT USE subPath, dynamic configmap updates don't work on subPaths + - name: config-volume + mountPath: /config restartPolicy: Always schedulerName: default-scheduler terminationGracePeriodSeconds: 30 @@ -100,3 +102,22 @@ spec: medium: Memory - name: adapters emptyDir: {} + - name: config-volume + configMap: + name: vllm-llama2-7b-adapters +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-llama2-7b-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama2-7b + port: 8000 + ensureExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + diff --git a/site-src/guides/adapter-rollout.md b/site-src/guides/adapter-rollout.md new file mode 100644 index 00000000..9ce8c3a4 --- /dev/null +++ b/site-src/guides/adapter-rollout.md @@ -0,0 +1,133 @@ +# Adapter Rollout + +The goal of this guide is to demonstrate how to rollout a new adapter version. + +## **Prerequisites** + +Follow the steps in the [main guide](index.md) + + +## **Safely rollout v2 adapter** + +### Load the new adapter version to the model servers + +This guide leverages the LoRA syncer sidecar to dynamically manage adapters within a vLLM deployment, enabling users to add or remove them through a shared ConfigMap. + + +Modify the LoRA syncer ConfigMap to initiate loading of the new adapter version. + + +```bash + kubectl edit configmap vllm-llama2-7b-adapters +``` + +Change the ConfigMap to match the following (note the new entry under models): + +```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: vllm-llama2-7b-adapters + data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama2-7b-adapters + port: 8000 + ensureExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-2 + source: mahimairaja/tweet-summarization-llama-2-finetuned +``` + +The new adapter version is applied to the model servers live, without requiring a restart. + + +### Direct traffic to the new adapter version + +Modify the InferenceModel to configure a canary rollout with traffic splitting. In this example, 10% of traffic for tweet-summary model will be sent to the new ***tweet-summary-2*** adapter. + + +```bash + kubectl edit inferencemodel tweet-summary +``` + +Change the targetModels list in InferenceModel to match the following: + + +```yaml +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: InferenceModel +metadata: + name: inferencemodel-sample +spec: + modelName: tweet-summary + criticality: Critical + poolRef: + name: vllm-llama2-7b-pool + targetModels: + - name: tweet-summary-1 + weight: 90 + - name: tweet-summary-2 + weight: 10 + +``` + +The above configuration means one in every ten requests should be sent to the new version. Try it out: + +1. Get the gateway IP: +```bash +IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}'); PORT=8081 +``` + +2. Send a few requests as follows: +```bash +curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ +"model": "tweet-summary", +"prompt": "Write as if you were a critic: San Francisco", +"max_tokens": 100, +"temperature": 0 +}' +``` + +### Finish the rollout + + +Modify the InferenceModel to direct 100% of the traffic to the latest version of the adapter. + +```yaml +model: + name: tweet-summary + targetModels: + targetModelName: tweet-summary-2 + weight: 100 +``` + +Unload the older versions from the servers by updating the LoRA syncer ConfigMap to list the older version under the `ensureNotExist` list: + +```yaml + apiVersion: v1 + kind: ConfigMap + metadata: + name: dynamic-lora-config + data: + configmap.yaml: | + vLLMLoRAConfig: + name: sql-loras-llama + port: 8000 + ensureExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-2 + source: mahimairaja/tweet-summarization-llama-2-finetuned + ensureNotExist: + models: + - base-model: meta-llama/Llama-2-7b-hf + id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm +``` + +With this, all requests should be served by the new adapter version. diff --git a/site-src/guides/dynamic-lora.md b/site-src/guides/dynamic-lora.md deleted file mode 100644 index ef3c2b0f..00000000 --- a/site-src/guides/dynamic-lora.md +++ /dev/null @@ -1,93 +0,0 @@ -# Getting started with Gateway API Inference Extension with Dynamic lora updates on vllm - -The goal of this guide is to get a single InferencePool running with vLLM and demonstrate use of dynamic lora updating! - -### Requirements - - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - - A cluster with: - - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, - you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - - 3 GPUs to run the sample model server. Adjust the number of replicas in `./manifests/vllm/deployment.yaml` as needed. - -### Steps - -1. **Deploy Sample VLLM Model Server with dynamic lora update enabled and dynamic lora syncer sidecar ** - [Redeploy the vLLM deployment with Dynamic lora adapter enabled and Lora syncer sidecar and configmap](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/pkg/manifests/vllm/dynamic-lora-sidecar/deployment.yaml) - -Rest of the steps are same as [general setup](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/site-src/guides/index.md) - - -### Safely rollout v2 adapter - -1. Update the LoRA syncer ConfigMap to make the new adapter version available on the model servers. - -```yaml - apiVersion: v1 - kind: ConfigMap - metadata: - name: dynamic-lora-config - data: - configmap.yaml: | - vLLMLoRAConfig: - name: sql-loras-llama - port: 8000 - ensureExist: - models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-0 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-2 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm -2. Configure a canary rollout with traffic split using LLMService. In this example, 40% of traffic for tweet-summary model will be sent to the ***tweet-summary-2*** adapter . - -```yaml -model: - name: tweet-summary - targetModels: - targetModelName: tweet-summary-0 - weight: 20 - targetModelName: tweet-summary-1 - weight: 40 - targetModelName: tweet-summary-2 - weight: 40 - -``` - -3. Finish rollout by setting the traffic to the new version 100%. -```yaml -model: - name: tweet-summary - targetModels: - targetModelName: tweet-summary-2 - weight: 100 -``` - -4. Remove v1 from dynamic lora configmap. -```yaml - apiVersion: v1 - kind: ConfigMap - metadata: - name: dynamic-lora-config - data: - configmap.yaml: | - vLLMLoRAConfig: - name: sql-loras-llama - port: 8000 - ensureExist: - models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-2 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - ensureNotExist: - models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-1 - source: gs://[HUGGING FACE PATH] - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-0 - source: gs://[HUGGING FACE PATH] -``` diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 2cc971c6..b9c38d87 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -2,16 +2,16 @@ This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get a first, single InferencePool up and running! -### Requirements +## **Prerequisites** - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - A cluster with: - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - 3 GPUs to run the sample model server. Adjust the number of replicas in `./manifests/vllm/deployment.yaml` as needed. -### Steps +## **Steps** -1. **Deploy Sample Model Server** +### Deploy Sample Model Server Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. @@ -20,22 +20,20 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/vllm/deployment.yaml ``` +### Install the Inference Extension CRDs - - -1. **Install the Inference Extension CRDs:** - - ```sh + ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v0.1.0/manifests.yaml -1. **Deploy InferenceModel** +### Deploy InferenceModel Deploy the sample InferenceModel which is configured to load balance traffic between the `tweet-summary-0` and `tweet-summary-1` [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/inferencemodel.yaml ``` -1. **Update Envoy Gateway Config to enable Patch Policy** + +### Update Envoy Gateway Config to enable Patch Policy** Our custom LLM Gateway ext-proc is patched into the existing envoy gateway via `EnvoyPatchPolicy`. To enable this feature, we must extend the Envoy Gateway config map. To do this, simply run: ```bash @@ -43,7 +41,8 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl rollout restart deployment envoy-gateway -n envoy-gateway-system ``` Additionally, if you would like to enable the admin interface, you can uncomment the admin lines and run this again. -1. **Deploy Gateway** + +### Deploy Gateway ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/gateway.yaml @@ -56,26 +55,28 @@ This quickstart guide is intended for engineers familiar with k8s and model serv NAME CLASS ADDRESS PROGRAMMED AGE inference-gateway inference-gateway True 22s ``` -1. **Deploy the Inference Extension and InferencePool** +### Deploy the Inference Extension and InferencePool ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/ext_proc.yaml ``` -1. **Deploy Envoy Gateway Custom Policies** +### Deploy Envoy Gateway Custom Policies ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/extension_policy.yaml kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/patch_policy.yaml ``` > **_NOTE:_** This is also per InferencePool, and will need to be configured to support the new pool should you wish to experiment further. -1. **OPTIONALLY**: Apply Traffic Policy + +### **OPTIONALLY**: Apply Traffic Policy For high-traffic benchmarking you can apply this manifest to avoid any defaults that can cause timeouts/errors. ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/traffic_policy.yaml ``` -1. **Try it out** + +### Try it out Wait until the gateway is ready. @@ -89,4 +90,4 @@ This quickstart guide is intended for engineers familiar with k8s and model serv "max_tokens": 100, "temperature": 0 }' - ``` \ No newline at end of file + ``` From 5705c5825b3e1e91567a89c39543ba6e98af0fc3 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Tue, 18 Feb 2025 21:34:26 +0000 Subject: [PATCH 030/260] Consolidating all storage behind datastore (#350) * Removed the intermediate cache in provider, and consolidating all storage behind datastore. * Fixed the provider test and covered the pool deletion events. * Don't store the port number with the pods * Address pod ip address updates * rename PodFlushAll to PodResyncAll * Addressed first round of comments * Addressed more comments * Adding a comment --- pkg/ext-proc/backend/datastore.go | 256 +++++++++----- pkg/ext-proc/backend/datastore_test.go | 6 +- pkg/ext-proc/backend/fake.go | 13 +- .../backend/inferencemodel_reconciler.go | 10 +- .../backend/inferencemodel_reconciler_test.go | 52 +-- .../backend/inferencepool_reconciler.go | 42 ++- .../backend/inferencepool_reconciler_test.go | 189 +++++++---- pkg/ext-proc/backend/pod_reconciler.go | 31 +- pkg/ext-proc/backend/pod_reconciler_test.go | 161 +++++++-- pkg/ext-proc/backend/provider.go | 134 +++----- pkg/ext-proc/backend/provider_test.go | 95 ++++-- pkg/ext-proc/backend/types.go | 21 +- pkg/ext-proc/backend/vllm/metrics.go | 11 +- pkg/ext-proc/handlers/request.go | 21 +- pkg/ext-proc/handlers/server.go | 24 +- pkg/ext-proc/health.go | 4 +- pkg/ext-proc/main.go | 16 +- pkg/ext-proc/scheduling/filter_test.go | 23 +- pkg/ext-proc/scheduling/scheduler.go | 27 +- pkg/ext-proc/server/runserver.go | 17 +- pkg/ext-proc/server/runserver_test.go | 2 +- pkg/ext-proc/test/benchmark/benchmark.go | 12 +- pkg/ext-proc/test/utils.go | 52 ++- pkg/ext-proc/util/testing/wrappers.go | 50 +++ pkg/manifests/vllm/deployment.yaml | 2 +- test/integration/hermetic_test.go | 320 ++++++++---------- 26 files changed, 935 insertions(+), 656 deletions(-) create mode 100644 pkg/ext-proc/util/testing/wrappers.go diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/backend/datastore.go index a75e7e43..6b8483d3 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/backend/datastore.go @@ -4,142 +4,209 @@ import ( "context" "errors" "math/rand" - "strconv" "sync" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) -func NewK8sDataStore(options ...K8sDatastoreOption) *K8sDatastore { - store := &K8sDatastore{ - poolMu: sync.RWMutex{}, - InferenceModels: &sync.Map{}, - pods: &sync.Map{}, - } - for _, opt := range options { - opt(store) +// The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) +type Datastore interface { + // InferencePool operations + PoolSet(pool *v1alpha1.InferencePool) + PoolGet() (*v1alpha1.InferencePool, error) + PoolHasSynced() bool + PoolLabelsMatch(podLabels map[string]string) bool + + // InferenceModel operations + ModelSet(infModel *v1alpha1.InferenceModel) + ModelGet(modelName string) (*v1alpha1.InferenceModel, bool) + ModelDelete(modelName string) + + // PodMetrics operations + PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool + PodUpdateMetricsIfExist(namespacedName types.NamespacedName, m *Metrics) bool + PodGet(namespacedName types.NamespacedName) (*PodMetrics, bool) + PodDelete(namespacedName types.NamespacedName) + PodResyncAll(ctx context.Context, ctrlClient client.Client) + PodGetAll() []*PodMetrics + PodDeleteAll() // This is only for testing. + PodRange(f func(key, value any) bool) + + // Clears the store state, happens when the pool gets deleted. + Clear() +} + +func NewDatastore() Datastore { + store := &datastore{ + poolMu: sync.RWMutex{}, + models: &sync.Map{}, + pods: &sync.Map{}, } return store } -// The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) -type K8sDatastore struct { +type datastore struct { // poolMu is used to synchronize access to the inferencePool. - poolMu sync.RWMutex - inferencePool *v1alpha1.InferencePool - InferenceModels *sync.Map - pods *sync.Map + poolMu sync.RWMutex + pool *v1alpha1.InferencePool + models *sync.Map + // key: types.NamespacedName, value: *PodMetrics + pods *sync.Map } -type K8sDatastoreOption func(*K8sDatastore) - -// WithPods can be used in tests to override the pods. -func WithPods(pods []*PodMetrics) K8sDatastoreOption { - return func(store *K8sDatastore) { - store.pods = &sync.Map{} - for _, pod := range pods { - store.pods.Store(pod.Pod, true) - } - } +func (ds *datastore) Clear() { + ds.poolMu.Lock() + defer ds.poolMu.Unlock() + ds.pool = nil + ds.models.Clear() + ds.pods.Clear() } -func (ds *K8sDatastore) setInferencePool(pool *v1alpha1.InferencePool) { +// /// InferencePool APIs /// +func (ds *datastore) PoolSet(pool *v1alpha1.InferencePool) { ds.poolMu.Lock() defer ds.poolMu.Unlock() - ds.inferencePool = pool + ds.pool = pool } -func (ds *K8sDatastore) getInferencePool() (*v1alpha1.InferencePool, error) { +func (ds *datastore) PoolGet() (*v1alpha1.InferencePool, error) { ds.poolMu.RLock() defer ds.poolMu.RUnlock() - if !ds.HasSynced() { + if !ds.PoolHasSynced() { return nil, errors.New("InferencePool is not initialized in data store") } - return ds.inferencePool, nil + return ds.pool, nil } -func (ds *K8sDatastore) GetPodIPs() []string { - var ips []string - ds.pods.Range(func(name, pod any) bool { - ips = append(ips, pod.(*corev1.Pod).Status.PodIP) - return true - }) - return ips +func (ds *datastore) PoolHasSynced() bool { + ds.poolMu.RLock() + defer ds.poolMu.RUnlock() + return ds.pool != nil +} + +func (ds *datastore) PoolLabelsMatch(podLabels map[string]string) bool { + poolSelector := selectorFromInferencePoolSelector(ds.pool.Spec.Selector) + podSet := labels.Set(podLabels) + return poolSelector.Matches(podSet) } -func (s *K8sDatastore) FetchModelData(modelName string) (returnModel *v1alpha1.InferenceModel) { - infModel, ok := s.InferenceModels.Load(modelName) +// /// InferenceModel APIs /// +func (ds *datastore) ModelSet(infModel *v1alpha1.InferenceModel) { + ds.models.Store(infModel.Spec.ModelName, infModel) +} + +func (ds *datastore) ModelGet(modelName string) (*v1alpha1.InferenceModel, bool) { + infModel, ok := ds.models.Load(modelName) if ok { - returnModel = infModel.(*v1alpha1.InferenceModel) + return infModel.(*v1alpha1.InferenceModel), true } - return + return nil, false } -// HasSynced returns true if InferencePool is set in the data store. -func (ds *K8sDatastore) HasSynced() bool { - ds.poolMu.RLock() - defer ds.poolMu.RUnlock() - return ds.inferencePool != nil +func (ds *datastore) ModelDelete(modelName string) { + ds.models.Delete(modelName) } -func RandomWeightedDraw(logger logr.Logger, model *v1alpha1.InferenceModel, seed int64) string { - var weights int32 - - source := rand.NewSource(rand.Int63()) - if seed > 0 { - source = rand.NewSource(seed) - } - r := rand.New(source) - for _, model := range model.Spec.TargetModels { - weights += *model.Weight +// /// Pods/endpoints APIs /// +func (ds *datastore) PodUpdateMetricsIfExist(namespacedName types.NamespacedName, m *Metrics) bool { + if val, ok := ds.pods.Load(namespacedName); ok { + existing := val.(*PodMetrics) + existing.Metrics = *m + return true } - logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) - randomVal := r.Int31n(weights) - for _, model := range model.Spec.TargetModels { - if randomVal < *model.Weight { - return model.Name - } - randomVal -= *model.Weight + return false +} + +func (ds *datastore) PodGet(namespacedName types.NamespacedName) (*PodMetrics, bool) { + val, ok := ds.pods.Load(namespacedName) + if ok { + return val.(*PodMetrics), true } - return "" + return nil, false } -func IsCritical(model *v1alpha1.InferenceModel) bool { - if model.Spec.Criticality != nil && *model.Spec.Criticality == v1alpha1.Critical { +func (ds *datastore) PodGetAll() []*PodMetrics { + res := []*PodMetrics{} + fn := func(k, v any) bool { + res = append(res, v.(*PodMetrics)) return true } - return false + ds.pods.Range(fn) + return res } -func (ds *K8sDatastore) LabelsMatch(podLabels map[string]string) bool { - poolSelector := selectorFromInferencePoolSelector(ds.inferencePool.Spec.Selector) - podSet := labels.Set(podLabels) - return poolSelector.Matches(podSet) +func (ds *datastore) PodRange(f func(key, value any) bool) { + ds.pods.Range(f) +} + +func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { + ds.pods.Delete(namespacedName) +} + +func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool { + new := &PodMetrics{ + Pod: Pod{ + NamespacedName: types.NamespacedName{ + Name: pod.Name, + Namespace: pod.Namespace, + }, + Address: pod.Status.PodIP, + }, + Metrics: Metrics{ + ActiveModels: make(map[string]int), + }, + } + existing, ok := ds.pods.Load(new.NamespacedName) + if !ok { + ds.pods.Store(new.NamespacedName, new) + return true + } + + // Update pod properties if anything changed. + existing.(*PodMetrics).Pod = new.Pod + return false } -func (ds *K8sDatastore) flushPodsAndRefetch(ctx context.Context, ctrlClient client.Client, newServerPool *v1alpha1.InferencePool) { +func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client) { + // Pool must exist to invoke this function. + pool, _ := ds.PoolGet() podList := &corev1.PodList{} if err := ctrlClient.List(ctx, podList, &client.ListOptions{ - LabelSelector: selectorFromInferencePoolSelector(newServerPool.Spec.Selector), - Namespace: newServerPool.Namespace, + LabelSelector: selectorFromInferencePoolSelector(pool.Spec.Selector), + Namespace: pool.Namespace, }); err != nil { log.FromContext(ctx).V(logutil.DEFAULT).Error(err, "Failed to list clients") + return } - ds.pods.Clear() - for _, k8sPod := range podList.Items { - pod := Pod{ - Name: k8sPod.Name, - Address: k8sPod.Status.PodIP + ":" + strconv.Itoa(int(newServerPool.Spec.TargetPortNumber)), + activePods := make(map[string]bool) + for _, pod := range podList.Items { + if podIsReady(&pod) { + activePods[pod.Name] = true + ds.PodUpdateOrAddIfNotExist(&pod) } - ds.pods.Store(pod, true) } + + // Remove pods that don't exist or not ready any more. + deleteFn := func(k, v any) bool { + pm := v.(*PodMetrics) + if exist := activePods[pm.NamespacedName.Name]; !exist { + ds.pods.Delete(pm.NamespacedName) + } + return true + } + ds.pods.Range(deleteFn) +} + +func (ds *datastore) PodDeleteAll() { + ds.pods.Clear() } func selectorFromInferencePoolSelector(selector map[v1alpha1.LabelKey]v1alpha1.LabelValue) labels.Selector { @@ -153,3 +220,32 @@ func stripLabelKeyAliasFromLabelMap(labels map[v1alpha1.LabelKey]v1alpha1.LabelV } return outMap } + +func RandomWeightedDraw(logger logr.Logger, model *v1alpha1.InferenceModel, seed int64) string { + var weights int32 + + source := rand.NewSource(rand.Int63()) + if seed > 0 { + source = rand.NewSource(seed) + } + r := rand.New(source) + for _, model := range model.Spec.TargetModels { + weights += *model.Weight + } + logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) + randomVal := r.Int31n(weights) + for _, model := range model.Spec.TargetModels { + if randomVal < *model.Weight { + return model.Name + } + randomVal -= *model.Weight + } + return "" +} + +func IsCritical(model *v1alpha1.InferenceModel) bool { + if model.Spec.Criticality != nil && *model.Spec.Criticality == v1alpha1.Critical { + return true + } + return false +} diff --git a/pkg/ext-proc/backend/datastore_test.go b/pkg/ext-proc/backend/datastore_test.go index 9f74226a..b44de0a5 100644 --- a/pkg/ext-proc/backend/datastore_test.go +++ b/pkg/ext-proc/backend/datastore_test.go @@ -32,13 +32,13 @@ func TestHasSynced(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - datastore := NewK8sDataStore() + datastore := NewDatastore() // Set the inference pool if tt.inferencePool != nil { - datastore.setInferencePool(tt.inferencePool) + datastore.PoolSet(tt.inferencePool) } // Check if the data store has been initialized - hasSynced := datastore.HasSynced() + hasSynced := datastore.PoolHasSynced() if hasSynced != tt.hasSynced { t.Errorf("IsInitialized() = %v, want %v", hasSynced, tt.hasSynced) } diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index 2c0757db..dfb520ef 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -3,22 +3,23 @@ package backend import ( "context" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type FakePodMetricsClient struct { - Err map[Pod]error - Res map[Pod]*PodMetrics + Err map[types.NamespacedName]error + Res map[types.NamespacedName]*PodMetrics } -func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod Pod, existing *PodMetrics) (*PodMetrics, error) { - if err, ok := f.Err[pod]; ok { +func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *PodMetrics) (*PodMetrics, error) { + if err, ok := f.Err[existing.NamespacedName]; ok { return nil, err } - log.FromContext(ctx).V(logutil.VERBOSE).Info("Fetching metrics for pod", "pod", pod, "existing", existing, "new", f.Res[pod]) - return f.Res[pod], nil + log.FromContext(ctx).V(logutil.VERBOSE).Info("Fetching metrics for pod", "existing", existing, "new", f.Res[existing.NamespacedName]) + return f.Res[existing.NamespacedName], nil } type FakeDataStore struct { diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/backend/inferencemodel_reconciler.go index 4959845c..884e6b7e 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler.go @@ -19,7 +19,7 @@ type InferenceModelReconciler struct { client.Client Scheme *runtime.Scheme Record record.EventRecorder - Datastore *K8sDatastore + Datastore Datastore PoolNamespacedName types.NamespacedName } @@ -36,14 +36,14 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque if err := c.Get(ctx, req.NamespacedName, infModel); err != nil { if errors.IsNotFound(err) { loggerDefault.Info("InferenceModel not found. Removing from datastore since object must be deleted", "name", req.NamespacedName) - c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) + c.Datastore.ModelDelete(infModel.Spec.ModelName) return ctrl.Result{}, nil } loggerDefault.Error(err, "Unable to get InferenceModel", "name", req.NamespacedName) return ctrl.Result{}, err } else if !infModel.DeletionTimestamp.IsZero() { loggerDefault.Info("InferenceModel is marked for deletion. Removing from datastore", "name", req.NamespacedName) - c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) + c.Datastore.ModelDelete(infModel.Spec.ModelName) return ctrl.Result{}, nil } @@ -57,12 +57,12 @@ func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel if infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name { loggerDefault.Info("Updating datastore", "poolRef", infModel.Spec.PoolRef, "serverPoolName", c.PoolNamespacedName) loggerDefault.Info("Adding/Updating InferenceModel", "modelName", infModel.Spec.ModelName) - c.Datastore.InferenceModels.Store(infModel.Spec.ModelName, infModel) + c.Datastore.ModelSet(infModel) return } loggerDefault.Info("Removing/Not adding InferenceModel", "modelName", infModel.Spec.ModelName) // If we get here. The model is not relevant to this pool, remove. - c.Datastore.InferenceModels.Delete(infModel.Spec.ModelName) + c.Datastore.ModelDelete(infModel.Spec.ModelName) } func (c *InferenceModelReconciler) SetupWithManager(mgr ctrl.Manager) error { diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go index 4e195818..5afe3b5a 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencemodel_reconciler_test.go @@ -51,14 +51,14 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { tests := []struct { name string - datastore *K8sDatastore + datastore *datastore incomingService *v1alpha1.InferenceModel wantInferenceModels *sync.Map }{ { name: "No Services registered; valid, new service incoming.", - datastore: &K8sDatastore{ - inferencePool: &v1alpha1.InferencePool{ + datastore: &datastore{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, @@ -67,15 +67,15 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: &sync.Map{}, + models: &sync.Map{}, }, incomingService: infModel1, wantInferenceModels: populateServiceMap(infModel1), }, { name: "Removing existing service.", - datastore: &K8sDatastore{ - inferencePool: &v1alpha1.InferencePool{ + datastore: &datastore{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, @@ -84,15 +84,15 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: populateServiceMap(infModel1), + models: populateServiceMap(infModel1), }, incomingService: infModel1Modified, wantInferenceModels: populateServiceMap(), }, { name: "Unrelated service, do nothing.", - datastore: &K8sDatastore{ - inferencePool: &v1alpha1.InferencePool{ + datastore: &datastore{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, @@ -101,7 +101,7 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: populateServiceMap(infModel1), + models: populateServiceMap(infModel1), }, incomingService: &v1alpha1.InferenceModel{ Spec: v1alpha1.InferenceModelSpec{ @@ -116,8 +116,8 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { }, { name: "Add to existing", - datastore: &K8sDatastore{ - inferencePool: &v1alpha1.InferencePool{ + datastore: &datastore{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, @@ -126,7 +126,7 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { ResourceVersion: "Old and boring", }, }, - InferenceModels: populateServiceMap(infModel1), + models: populateServiceMap(infModel1), }, incomingService: infModel2, wantInferenceModels: populateServiceMap(infModel1, infModel2), @@ -136,11 +136,11 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { t.Run(test.name, func(t *testing.T) { reconciler := &InferenceModelReconciler{ Datastore: test.datastore, - PoolNamespacedName: types.NamespacedName{Name: test.datastore.inferencePool.Name}, + PoolNamespacedName: types.NamespacedName{Name: test.datastore.pool.Name}, } reconciler.updateDatastore(logger, test.incomingService) - if ok := mapsEqual(reconciler.Datastore.InferenceModels, test.wantInferenceModels); !ok { + if ok := mapsEqual(test.datastore.models, test.wantInferenceModels); !ok { t.Error("Maps are not equal") } }) @@ -156,9 +156,9 @@ func TestReconcile_ResourceNotFound(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() // Create a minimal datastore. - datastore := &K8sDatastore{ - InferenceModels: &sync.Map{}, - inferencePool: &v1alpha1.InferencePool{ + datastore := &datastore{ + models: &sync.Map{}, + pool: &v1alpha1.InferencePool{ ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, }, } @@ -211,9 +211,9 @@ func TestReconcile_ModelMarkedForDeletion(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() // Create a minimal datastore. - datastore := &K8sDatastore{ - InferenceModels: &sync.Map{}, - inferencePool: &v1alpha1.InferencePool{ + datastore := &datastore{ + models: &sync.Map{}, + pool: &v1alpha1.InferencePool{ ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, }, } @@ -242,7 +242,7 @@ func TestReconcile_ModelMarkedForDeletion(t *testing.T) { } // Verify that the datastore was not updated. - if _, ok := datastore.InferenceModels.Load(existingModel.Spec.ModelName); ok { + if _, exist := datastore.ModelGet(existingModel.Spec.ModelName); exist { t.Errorf("expected datastore to not contain model %q", existingModel.Spec.ModelName) } } @@ -268,9 +268,9 @@ func TestReconcile_ResourceExists(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() // Create a minimal datastore. - datastore := &K8sDatastore{ - InferenceModels: &sync.Map{}, - inferencePool: &v1alpha1.InferencePool{ + datastore := &datastore{ + models: &sync.Map{}, + pool: &v1alpha1.InferencePool{ ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, }, } @@ -299,7 +299,7 @@ func TestReconcile_ResourceExists(t *testing.T) { } // Verify that the datastore was updated. - if _, ok := datastore.InferenceModels.Load(existingModel.Spec.ModelName); !ok { + if _, exist := datastore.ModelGet(existingModel.Spec.ModelName); !exist { t.Errorf("expected datastore to contain model %q", existingModel.Spec.ModelName) } } diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/backend/inferencepool_reconciler.go index e44a278a..6f52862e 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler.go @@ -4,11 +4,10 @@ import ( "context" "reflect" - "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" - klog "k8s.io/klog/v2" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -24,7 +23,7 @@ type InferencePoolReconciler struct { Scheme *runtime.Scheme Record record.EventRecorder PoolNamespacedName types.NamespacedName - Datastore *K8sDatastore + Datastore Datastore } func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -37,26 +36,39 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques loggerDefault.Info("Reconciling InferencePool", "name", req.NamespacedName) serverPool := &v1alpha1.InferencePool{} + if err := c.Get(ctx, req.NamespacedName, serverPool); err != nil { + if errors.IsNotFound(err) { + loggerDefault.Info("InferencePool not found. Clearing the datastore", "name", req.NamespacedName) + c.Datastore.Clear() + return ctrl.Result{}, nil + } loggerDefault.Error(err, "Unable to get InferencePool", "name", req.NamespacedName) return ctrl.Result{}, err - } - if c.Datastore.inferencePool == nil || !reflect.DeepEqual(serverPool.Spec.Selector, c.Datastore.inferencePool.Spec.Selector) { - c.updateDatastore(logger, serverPool) - c.Datastore.flushPodsAndRefetch(ctx, c.Client, serverPool) - } else { - c.updateDatastore(logger, serverPool) + } else if !serverPool.DeletionTimestamp.IsZero() { + loggerDefault.Info("InferencePool is marked for deletion. Clearing the datastore", "name", req.NamespacedName) + c.Datastore.Clear() + return ctrl.Result{}, nil } + c.updateDatastore(ctx, serverPool) + return ctrl.Result{}, nil } -func (c *InferencePoolReconciler) updateDatastore(logger logr.Logger, serverPool *v1alpha1.InferencePool) { - pool, _ := c.Datastore.getInferencePool() - if pool == nil || - serverPool.ObjectMeta.ResourceVersion != pool.ObjectMeta.ResourceVersion { - logger.V(logutil.DEFAULT).Info("Updating inference pool", "target", klog.KMetadata(&serverPool.ObjectMeta)) - c.Datastore.setInferencePool(serverPool) +func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool *v1alpha1.InferencePool) { + logger := log.FromContext(ctx) + oldPool, err := c.Datastore.PoolGet() + c.Datastore.PoolSet(newPool) + if err != nil || !reflect.DeepEqual(newPool.Spec.Selector, oldPool.Spec.Selector) { + logger.V(logutil.DEFAULT).Info("Updating inference pool endpoints", "selector", newPool.Spec.Selector) + // A full resync is required to address two cases: + // 1) At startup, the pod events may get processed before the pool is synced with the datastore, + // and hence they will not be added to the store since pool selector is not known yet + // 2) If the selector on the pool was updated, then we will not get any pod events, and so we need + // to resync the whole pool: remove pods in the store that don't match the new selector and add + // the ones that may have existed already to the store. + c.Datastore.PodResyncAll(ctx, c.Client) } } diff --git a/pkg/ext-proc/backend/inferencepool_reconciler_test.go b/pkg/ext-proc/backend/inferencepool_reconciler_test.go index 1da7d61b..b6403489 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler_test.go +++ b/pkg/ext-proc/backend/inferencepool_reconciler_test.go @@ -1,88 +1,153 @@ package backend import ( - "reflect" + "context" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" ) var ( - pool1 = &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, + selector_v1 = map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v1"} + selector_v2 = map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v2"} + pool1 = &v1alpha1.InferencePool{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "50", + Name: "pool1", + Namespace: "pool1-ns", }, - } - // Different name, same RV doesn't really make sense, but helps with testing the - // updateStore impl which relies on the equality of RVs alone. - modPool1SameRV = &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool-mod", - ResourceVersion: "50", + Selector: selector_v1, + TargetPortNumber: 8080, }, } - modPool1DiffRV = &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, + pool2 = &v1alpha1.InferencePool{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool-mod", - ResourceVersion: "51", + Name: "pool2", + Namespace: "pool2-ns", }, } + pods = []corev1.Pod{ + // Two ready pods matching pool1 + utiltesting.MakePod("pod1", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).ReadyCondition().Obj(), + utiltesting.MakePod("pod2", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).ReadyCondition().Obj(), + // A not ready pod matching pool1 + utiltesting.MakePod("pod3", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).Obj(), + // A pod not matching pool1 namespace + utiltesting.MakePod("pod4", "pool2-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).ReadyCondition().Obj(), + // A ready pod matching pool1 with a new selector + utiltesting.MakePod("pod5", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v2)).ReadyCondition().Obj(), + } ) -func TestUpdateDatastore_InferencePoolReconciler(t *testing.T) { - logger := logutil.NewTestLogger() +func TestReconcile_InferencePoolReconciler(t *testing.T) { + // The best practice is to use table-driven tests, however in this scaenario it seems + // more logical to do a single test with steps that depend on each other. - tests := []struct { - name string - datastore *K8sDatastore - incomingPool *v1alpha1.InferencePool - wantPool *v1alpha1.InferencePool - }{ - { - name: "InferencePool not set, should set InferencePool", - datastore: &K8sDatastore{}, - incomingPool: pool1.DeepCopy(), - wantPool: pool1, - }, - { - name: "InferencePool set, matching RVs, do nothing", - datastore: &K8sDatastore{ - inferencePool: pool1.DeepCopy(), - }, - incomingPool: modPool1SameRV.DeepCopy(), - wantPool: pool1, - }, - { - name: "InferencePool set, differing RVs, re-set InferencePool", - datastore: &K8sDatastore{ - inferencePool: pool1.DeepCopy(), - }, - incomingPool: modPool1DiffRV.DeepCopy(), - wantPool: modPool1DiffRV, - }, + // Set up the scheme. + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = v1alpha1.AddToScheme(scheme) + + // Create a fake client with the pool and the pods. + initialObjects := []client.Object{pool1, pool2} + for i := range pods { + initialObjects = append(initialObjects, &pods[i]) } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(initialObjects...). + Build() + + // Create a request for the existing resource. + namespacedName := types.NamespacedName{Name: pool1.Name, Namespace: pool1.Namespace} + req := ctrl.Request{NamespacedName: namespacedName} + ctx := context.Background() - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - inferencePoolReconciler := &InferencePoolReconciler{Datastore: test.datastore} - inferencePoolReconciler.updateDatastore(logger, test.incomingPool) + datastore := NewDatastore() + inferencePoolReconciler := &InferencePoolReconciler{PoolNamespacedName: namespacedName, Client: fakeClient, Datastore: datastore} + + // Step 1: Inception, only ready pods matching pool1 are added to the store. + if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { + t.Errorf("Unexpected InferencePool reconcile error: %v", err) + } + if diff := diffPool(datastore, pool1, []string{"pod1", "pod2"}); diff != "" { + t.Errorf("Unexpected diff (+got/-want): %s", diff) + } + + // Step 2: A reconcile on pool2 should not change anything. + if _, err := inferencePoolReconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: pool2.Name, Namespace: pool2.Namespace}}); err != nil { + t.Errorf("Unexpected InferencePool reconcile error: %v", err) + } + if diff := diffPool(datastore, pool1, []string{"pod1", "pod2"}); diff != "" { + t.Errorf("Unexpected diff (+got/-want): %s", diff) + } - gotPool := inferencePoolReconciler.Datastore.inferencePool - if !reflect.DeepEqual(gotPool, test.wantPool) { - t.Errorf("Unexpected InferencePool: want %#v, got: %#v", test.wantPool, gotPool) - } - }) + // Step 3: update the pool selector to include more pods + newPool1 := &v1alpha1.InferencePool{} + if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { + t.Errorf("Unexpected pool get error: %v", err) + } + newPool1.Spec.Selector = selector_v2 + if err := fakeClient.Update(ctx, newPool1, &client.UpdateOptions{}); err != nil { + t.Errorf("Unexpected pool update error: %v", err) + } + + if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { + t.Errorf("Unexpected InferencePool reconcile error: %v", err) + } + if diff := diffPool(datastore, newPool1, []string{"pod5"}); diff != "" { + t.Errorf("Unexpected diff (+got/-want): %s", diff) + } + + // Step 4: update the pool port + if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { + t.Errorf("Unexpected pool get error: %v", err) + } + newPool1.Spec.TargetPortNumber = 9090 + if err := fakeClient.Update(ctx, newPool1, &client.UpdateOptions{}); err != nil { + t.Errorf("Unexpected pool update error: %v", err) + } + if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { + t.Errorf("Unexpected InferencePool reconcile error: %v", err) + } + if diff := diffPool(datastore, newPool1, []string{"pod5"}); diff != "" { + t.Errorf("Unexpected diff (+got/-want): %s", diff) + } + + // Step 5: delete the pool to trigger a datastore clear + if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { + t.Errorf("Unexpected pool get error: %v", err) + } + if err := fakeClient.Delete(ctx, newPool1, &client.DeleteOptions{}); err != nil { + t.Errorf("Unexpected pool delete error: %v", err) + } + if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { + t.Errorf("Unexpected InferencePool reconcile error: %v", err) + } + if diff := diffPool(datastore, nil, []string{}); diff != "" { + t.Errorf("Unexpected diff (+got/-want): %s", diff) + } +} + +func diffPool(datastore Datastore, wantPool *v1alpha1.InferencePool, wantPods []string) string { + gotPool, _ := datastore.PoolGet() + if diff := cmp.Diff(wantPool, gotPool); diff != "" { + return diff + } + gotPods := []string{} + for _, pm := range datastore.PodGetAll() { + gotPods = append(gotPods, pm.NamespacedName.Name) } + return cmp.Diff(wantPods, gotPods, cmpopts.SortSlices(func(a, b string) bool { return a < b })) } diff --git a/pkg/ext-proc/backend/pod_reconciler.go b/pkg/ext-proc/backend/pod_reconciler.go index b914ea8d..8705ce83 100644 --- a/pkg/ext-proc/backend/pod_reconciler.go +++ b/pkg/ext-proc/backend/pod_reconciler.go @@ -2,29 +2,29 @@ package backend import ( "context" - "strconv" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type PodReconciler struct { client.Client - Datastore *K8sDatastore + Datastore Datastore Scheme *runtime.Scheme Record record.EventRecorder } func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) - inferencePool, err := c.Datastore.getInferencePool() + inferencePool, err := c.Datastore.PoolGet() if err != nil { logger.V(logutil.TRACE).Info("Skipping reconciling Pod because the InferencePool is not available yet", "error", err) // When the inferencePool is initialized it lists the appropriate pods and populates the datastore, so no need to requeue. @@ -38,15 +38,14 @@ func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R pod := &corev1.Pod{} if err := c.Get(ctx, req.NamespacedName, pod); err != nil { if apierrors.IsNotFound(err) { - c.Datastore.pods.Delete(pod) + c.Datastore.PodDelete(req.NamespacedName) return ctrl.Result{}, nil } logger.V(logutil.DEFAULT).Error(err, "Unable to get pod", "name", req.NamespacedName) return ctrl.Result{}, err } - c.updateDatastore(pod, inferencePool) - + c.updateDatastore(logger, pod) return ctrl.Result{}, nil } @@ -56,15 +55,17 @@ func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(c) } -func (c *PodReconciler) updateDatastore(k8sPod *corev1.Pod, inferencePool *v1alpha1.InferencePool) { - pod := Pod{ - Name: k8sPod.Name, - Address: k8sPod.Status.PodIP + ":" + strconv.Itoa(int(inferencePool.Spec.TargetPortNumber)), - } - if !k8sPod.DeletionTimestamp.IsZero() || !c.Datastore.LabelsMatch(k8sPod.ObjectMeta.Labels) || !podIsReady(k8sPod) { - c.Datastore.pods.Delete(pod) +func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod) { + namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} + if !pod.DeletionTimestamp.IsZero() || !c.Datastore.PoolLabelsMatch(pod.Labels) || !podIsReady(pod) { + logger.V(logutil.DEFAULT).Info("Pod removed or not added", "name", namespacedName) + c.Datastore.PodDelete(namespacedName) } else { - c.Datastore.pods.Store(pod, true) + if c.Datastore.PodUpdateOrAddIfNotExist(pod) { + logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName) + } else { + logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName) + } } } diff --git a/pkg/ext-proc/backend/pod_reconciler_test.go b/pkg/ext-proc/backend/pod_reconciler_test.go index 42d6d8e4..cc7381f6 100644 --- a/pkg/ext-proc/backend/pod_reconciler_test.go +++ b/pkg/ext-proc/backend/pod_reconciler_test.go @@ -1,33 +1,43 @@ package backend import ( + "context" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" ) var ( - basePod1 = Pod{Name: "pod1", Address: ":8000"} - basePod2 = Pod{Name: "pod2", Address: ":8000"} - basePod3 = Pod{Name: "pod3", Address: ":8000"} + basePod1 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1"}} + basePod2 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2"}} + basePod3 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3"}} + basePod11 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11"}} ) func TestUpdateDatastore_PodReconciler(t *testing.T) { + now := metav1.Now() tests := []struct { name string - datastore *K8sDatastore + datastore Datastore incomingPod *corev1.Pod - wantPods []string + wantPods []Pod + req *ctrl.Request }{ { name: "Add new pod", - datastore: &K8sDatastore{ + datastore: &datastore{ pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ @@ -38,10 +48,76 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "pod3", + Name: basePod3.NamespacedName.Name, + Labels: map[string]string{ + "some-key": "some-val", + }, + }, + Status: corev1.PodStatus{ + PodIP: basePod3.Address, + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + wantPods: []Pod{basePod1.Pod, basePod2.Pod, basePod3.Pod}, + }, + { + name: "Update pod1 address", + datastore: &datastore{ + pods: populateMap(basePod1, basePod2), + pool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + incomingPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: basePod11.NamespacedName.Name, + Labels: map[string]string{ + "some-key": "some-val", + }, + }, + Status: corev1.PodStatus{ + PodIP: basePod11.Address, + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + wantPods: []Pod{basePod11.Pod, basePod2.Pod}, + }, + { + name: "Delete pod with DeletionTimestamp", + datastore: &datastore{ + pods: populateMap(basePod1, basePod2), + pool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + incomingPod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", Labels: map[string]string{ "some-key": "some-val", }, + DeletionTimestamp: &now, + Finalizers: []string{"finalizer"}, }, Status: corev1.PodStatus{ Conditions: []corev1.PodCondition{ @@ -52,13 +128,29 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []string{basePod1.Name, basePod2.Name, basePod3.Name}, + wantPods: []Pod{basePod2.Pod}, + }, + { + name: "Delete notfound pod", + datastore: &datastore{ + pods: populateMap(basePod1, basePod2), + pool: &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", + }, + }, + }, + }, + req: &ctrl.Request{NamespacedName: types.NamespacedName{Name: "pod1"}}, + wantPods: []Pod{basePod2.Pod}, }, { name: "New pod, not ready, valid selector", - datastore: &K8sDatastore{ + datastore: &datastore{ pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ @@ -83,13 +175,13 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []string{basePod1.Name, basePod2.Name}, + wantPods: []Pod{basePod1.Pod, basePod2.Pod}, }, { name: "Remove pod that does not match selector", - datastore: &K8sDatastore{ + datastore: &datastore{ pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ @@ -114,13 +206,13 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []string{basePod2.Name}, + wantPods: []Pod{basePod2.Pod}, }, { name: "Remove pod that is not ready", - datastore: &K8sDatastore{ + datastore: &datastore{ pods: populateMap(basePod1, basePod2), - inferencePool: &v1alpha1.InferencePool{ + pool: &v1alpha1.InferencePool{ Spec: v1alpha1.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ @@ -145,22 +237,41 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []string{basePod2.Name}, + wantPods: []Pod{basePod2.Pod}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - podReconciler := &PodReconciler{Datastore: test.datastore} - podReconciler.updateDatastore(test.incomingPod, test.datastore.inferencePool) - var gotPods []string - test.datastore.pods.Range(func(k, v any) bool { - pod := k.(Pod) + // Set up the scheme. + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + initialObjects := []client.Object{} + if test.incomingPod != nil { + initialObjects = append(initialObjects, test.incomingPod) + } + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(initialObjects...). + Build() + + podReconciler := &PodReconciler{Client: fakeClient, Datastore: test.datastore} + namespacedName := types.NamespacedName{Name: test.incomingPod.Name, Namespace: test.incomingPod.Namespace} + if test.req == nil { + test.req = &ctrl.Request{NamespacedName: namespacedName} + } + if _, err := podReconciler.Reconcile(context.Background(), *test.req); err != nil { + t.Errorf("Unexpected InferencePool reconcile error: %v", err) + } + + var gotPods []Pod + test.datastore.PodRange(func(k, v any) bool { + pod := v.(*PodMetrics) if v != nil { - gotPods = append(gotPods, pod.Name) + gotPods = append(gotPods, pod.Pod) } return true }) - if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b string) bool { return a < b })) { + if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b Pod) bool { return a.NamespacedName.String() < b.NamespacedName.String() })) { t.Errorf("got (%v) != want (%v);", gotPods, test.wantPods) } }) diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index ce738986..bb575d19 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -8,6 +8,7 @@ import ( "github.com/go-logr/logr" "go.uber.org/multierr" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -16,72 +17,38 @@ const ( fetchMetricsTimeout = 5 * time.Second ) -func NewProvider(pmc PodMetricsClient, datastore *K8sDatastore) *Provider { +func NewProvider(pmc PodMetricsClient, datastore Datastore) *Provider { p := &Provider{ - podMetrics: sync.Map{}, - pmc: pmc, - datastore: datastore, + pmc: pmc, + datastore: datastore, } return p } // Provider provides backend pods and information such as metrics. type Provider struct { - // key: Pod, value: *PodMetrics - podMetrics sync.Map - pmc PodMetricsClient - datastore *K8sDatastore + pmc PodMetricsClient + datastore Datastore } type PodMetricsClient interface { - FetchMetrics(ctx context.Context, pod Pod, existing *PodMetrics) (*PodMetrics, error) + FetchMetrics(ctx context.Context, existing *PodMetrics) (*PodMetrics, error) } -func (p *Provider) AllPodMetrics() []*PodMetrics { - res := []*PodMetrics{} - fn := func(k, v any) bool { - res = append(res, v.(*PodMetrics)) - return true - } - p.podMetrics.Range(fn) - return res -} - -func (p *Provider) UpdatePodMetrics(pod Pod, pm *PodMetrics) { - p.podMetrics.Store(pod, pm) -} - -func (p *Provider) GetPodMetrics(pod Pod) (*PodMetrics, bool) { - val, ok := p.podMetrics.Load(pod) - if ok { - return val.(*PodMetrics), true - } - return nil, false -} - -func (p *Provider) Init(logger logr.Logger, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { - p.refreshPodsOnce() - - if err := p.refreshMetricsOnce(logger); err != nil { - logger.Error(err, "Failed to init metrics") - } - - logger.Info("Initialized pods and metrics", "metrics", p.AllPodMetrics()) - - // periodically refresh pods - go func() { - for { - time.Sleep(refreshPodsInterval) - p.refreshPodsOnce() - } - }() - +func (p *Provider) Init(ctx context.Context, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { // periodically refresh metrics + logger := log.FromContext(ctx) go func() { for { - time.Sleep(refreshMetricsInterval) - if err := p.refreshMetricsOnce(logger); err != nil { - logger.V(logutil.DEFAULT).Error(err, "Failed to refresh metrics") + select { + case <-ctx.Done(): + logger.V(logutil.DEFAULT).Info("Shutting down metrics prober") + return + default: + time.Sleep(refreshMetricsInterval) + if err := p.refreshMetricsOnce(logger); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Failed to refresh metrics") + } } } }() @@ -89,8 +56,14 @@ func (p *Provider) Init(logger logr.Logger, refreshPodsInterval, refreshMetricsI // Periodically flush prometheus metrics for inference pool go func() { for { - time.Sleep(refreshPrometheusMetricsInterval) - p.flushPrometheusMetricsOnce(logger) + select { + case <-ctx.Done(): + logger.V(logutil.DEFAULT).Info("Shutting down prometheus metrics thread") + return + default: + time.Sleep(refreshPrometheusMetricsInterval) + p.flushPrometheusMetricsOnce(logger) + } } }() @@ -98,8 +71,14 @@ func (p *Provider) Init(logger logr.Logger, refreshPodsInterval, refreshMetricsI if logger := logger.V(logutil.DEBUG); logger.Enabled() { go func() { for { - time.Sleep(5 * time.Second) - logger.Info("Current Pods and metrics gathered", "metrics", p.AllPodMetrics()) + select { + case <-ctx.Done(): + logger.V(logutil.DEFAULT).Info("Shutting down metrics logger thread") + return + default: + time.Sleep(5 * time.Second) + logger.Info("Current Pods and metrics gathered", "metrics", p.datastore.PodGetAll()) + } } }() } @@ -107,36 +86,6 @@ func (p *Provider) Init(logger logr.Logger, refreshPodsInterval, refreshMetricsI return nil } -// refreshPodsOnce lists pods and updates keys in the podMetrics map. -// Note this function doesn't update the PodMetrics value, it's done separately. -func (p *Provider) refreshPodsOnce() { - // merge new pods with cached ones. - // add new pod to the map - addNewPods := func(k, v any) bool { - pod := k.(Pod) - if _, ok := p.podMetrics.Load(pod); !ok { - new := &PodMetrics{ - Pod: pod, - Metrics: Metrics{ - ActiveModels: make(map[string]int), - }, - } - p.podMetrics.Store(pod, new) - } - return true - } - // remove pods that don't exist any more. - mergeFn := func(k, v any) bool { - pod := k.(Pod) - if _, ok := p.datastore.pods.Load(pod); !ok { - p.podMetrics.Delete(pod) - } - return true - } - p.podMetrics.Range(mergeFn) - p.datastore.pods.Range(addNewPods) -} - func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { loggerTrace := logger.V(logutil.TRACE) ctx, cancel := context.WithTimeout(context.Background(), fetchMetricsTimeout) @@ -151,22 +100,21 @@ func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { errCh := make(chan error) processOnePod := func(key, value any) bool { loggerTrace.Info("Pod and metric being processed", "pod", key, "metric", value) - pod := key.(Pod) existing := value.(*PodMetrics) wg.Add(1) go func() { defer wg.Done() - updated, err := p.pmc.FetchMetrics(ctx, pod, existing) + updated, err := p.pmc.FetchMetrics(ctx, existing) if err != nil { - errCh <- fmt.Errorf("failed to parse metrics from %s: %v", pod, err) + errCh <- fmt.Errorf("failed to parse metrics from %s: %v", existing.NamespacedName, err) return } - p.UpdatePodMetrics(pod, updated) - loggerTrace.Info("Updated metrics for pod", "pod", pod, "metrics", updated.Metrics) + p.datastore.PodUpdateMetricsIfExist(updated.NamespacedName, &updated.Metrics) + loggerTrace.Info("Updated metrics for pod", "pod", updated.NamespacedName, "metrics", updated.Metrics) }() return true } - p.podMetrics.Range(processOnePod) + p.datastore.PodRange(processOnePod) // Wait for metric collection for all pods to complete and close the error channel in a // goroutine so this is unblocking, allowing the code to proceed to the error collection code @@ -188,7 +136,7 @@ func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { func (p *Provider) flushPrometheusMetricsOnce(logger logr.Logger) { logger.V(logutil.DEBUG).Info("Flushing Prometheus Metrics") - pool, _ := p.datastore.getInferencePool() + pool, _ := p.datastore.PoolGet() if pool == nil { // No inference pool or not initialize. return @@ -197,7 +145,7 @@ func (p *Provider) flushPrometheusMetricsOnce(logger logr.Logger) { var kvCacheTotal float64 var queueTotal int - podMetrics := p.AllPodMetrics() + podMetrics := p.datastore.PodGetAll() if len(podMetrics) == 0 { return } diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/ext-proc/backend/provider_test.go index 95575046..2aa2c213 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/ext-proc/backend/provider_test.go @@ -1,6 +1,7 @@ package backend import ( + "context" "errors" "sync" "testing" @@ -8,12 +9,17 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" ) var ( pod1 = &PodMetrics{ - Pod: Pod{Name: "pod1"}, + Pod: Pod{ + NamespacedName: types.NamespacedName{ + Name: "pod1", + }, + }, Metrics: Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -25,7 +31,11 @@ var ( }, } pod2 = &PodMetrics{ - Pod: Pod{Name: "pod2"}, + Pod: Pod{ + NamespacedName: types.NamespacedName{ + Name: "pod2", + }, + }, Metrics: Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.2, @@ -39,51 +49,65 @@ var ( ) func TestProvider(t *testing.T) { - logger := logutil.NewTestLogger() - tests := []struct { name string pmc PodMetricsClient - datastore *K8sDatastore - initErr bool + datastore Datastore want []*PodMetrics }{ { - name: "Init success", - datastore: &K8sDatastore{ - pods: populateMap(pod1.Pod, pod2.Pod), + name: "Probing metrics success", + pmc: &FakePodMetricsClient{ + Res: map[types.NamespacedName]*PodMetrics{ + pod1.NamespacedName: pod1, + pod2.NamespacedName: pod2, + }, }, + datastore: &datastore{ + pods: populateMap(pod1, pod2), + }, + want: []*PodMetrics{ + pod1, + pod2, + }, + }, + { + name: "Only pods in the datastore are probed", pmc: &FakePodMetricsClient{ - Res: map[Pod]*PodMetrics{ - pod1.Pod: pod1, - pod2.Pod: pod2, + Res: map[types.NamespacedName]*PodMetrics{ + pod1.NamespacedName: pod1, + pod2.NamespacedName: pod2, }, }, - want: []*PodMetrics{pod1, pod2}, + datastore: &datastore{ + pods: populateMap(pod1), + }, + want: []*PodMetrics{ + pod1, + }, }, { - name: "Fetch metrics error", + name: "Probing metrics error", pmc: &FakePodMetricsClient{ - Err: map[Pod]error{ - pod2.Pod: errors.New("injected error"), + Err: map[types.NamespacedName]error{ + pod2.NamespacedName: errors.New("injected error"), }, - Res: map[Pod]*PodMetrics{ - pod1.Pod: pod1, + Res: map[types.NamespacedName]*PodMetrics{ + pod1.NamespacedName: pod1, }, }, - datastore: &K8sDatastore{ - pods: populateMap(pod1.Pod, pod2.Pod), + datastore: &datastore{ + pods: populateMap(pod1, pod2), }, want: []*PodMetrics{ pod1, // Failed to fetch pod2 metrics so it remains the default values. { - Pod: Pod{Name: "pod2"}, + Pod: Pod{NamespacedName: pod2.NamespacedName}, Metrics: Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, MaxActiveModels: 0, - ActiveModels: map[string]int{}, }, }, }, @@ -93,25 +117,24 @@ func TestProvider(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { p := NewProvider(test.pmc, test.datastore) - err := p.Init(logger, time.Millisecond, time.Millisecond, time.Millisecond) - if test.initErr != (err != nil) { - t.Fatalf("Unexpected error, got: %v, want: %v", err, test.initErr) - } - metrics := p.AllPodMetrics() - lessFunc := func(a, b *PodMetrics) bool { - return a.String() < b.String() - } - if diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(lessFunc)); diff != "" { - t.Errorf("Unexpected output (-want +got): %v", diff) - } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + _ = p.Init(ctx, time.Millisecond, time.Millisecond) + assert.EventuallyWithT(t, func(t *assert.CollectT) { + metrics := test.datastore.PodGetAll() + diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(func(a, b *PodMetrics) bool { + return a.String() < b.String() + })) + assert.Equal(t, "", diff, "Unexpected diff (+got/-want)") + }, 5*time.Second, time.Millisecond) }) } } -func populateMap(pods ...Pod) *sync.Map { +func populateMap(pods ...*PodMetrics) *sync.Map { newMap := &sync.Map{} for _, pod := range pods { - newMap.Store(pod, true) + newMap.Store(pod.NamespacedName, &PodMetrics{Pod: Pod{NamespacedName: pod.NamespacedName, Address: pod.Address}}) } return newMap } diff --git a/pkg/ext-proc/backend/types.go b/pkg/ext-proc/backend/types.go index 7e399fed..0e02fb09 100644 --- a/pkg/ext-proc/backend/types.go +++ b/pkg/ext-proc/backend/types.go @@ -1,17 +1,15 @@ // Package backend is a library to interact with backend model servers such as probing metrics. package backend -import "fmt" +import ( + "fmt" -type PodSet map[Pod]bool + "k8s.io/apimachinery/pkg/types" +) type Pod struct { - Name string - Address string -} - -func (p Pod) String() string { - return p.Name + ":" + p.Address + NamespacedName types.NamespacedName + Address string } type Metrics struct { @@ -31,7 +29,7 @@ type PodMetrics struct { } func (pm *PodMetrics) String() string { - return fmt.Sprintf("Pod: %+v; Metrics: %+v", pm.Pod, pm.Metrics) + return fmt.Sprintf("Pod: %+v; Address: %+v; Metrics: %+v", pm.NamespacedName, pm.Address, pm.Metrics) } func (pm *PodMetrics) Clone() *PodMetrics { @@ -40,7 +38,10 @@ func (pm *PodMetrics) Clone() *PodMetrics { cm[k] = v } clone := &PodMetrics{ - Pod: pm.Pod, + Pod: Pod{ + NamespacedName: pm.NamespacedName, + Address: pm.Address, + }, Metrics: Metrics{ ActiveModels: cm, RunningQueueSize: pm.RunningQueueSize, diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index 4558a664..3737425d 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -38,7 +38,6 @@ type PodMetricsClientImpl struct{} // FetchMetrics fetches metrics from a given pod. func (p *PodMetricsClientImpl) FetchMetrics( ctx context.Context, - pod backend.Pod, existing *backend.PodMetrics, ) (*backend.PodMetrics, error) { logger := log.FromContext(ctx) @@ -46,7 +45,7 @@ func (p *PodMetricsClientImpl) FetchMetrics( // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. - url := fmt.Sprintf("http://%s/metrics", pod.Address) + url := fmt.Sprintf("http://%s/metrics", existing.Address) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { loggerDefault.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) @@ -54,16 +53,16 @@ func (p *PodMetricsClientImpl) FetchMetrics( } resp, err := http.DefaultClient.Do(req) if err != nil { - loggerDefault.Error(err, "Failed to fetch metrics", "pod", pod) - return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod, err) + loggerDefault.Error(err, "Failed to fetch metrics", "pod", existing.NamespacedName) + return nil, fmt.Errorf("failed to fetch metrics from %s: %w", existing.NamespacedName, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - loggerDefault.Error(nil, "Unexpected status code returned", "pod", pod, "statusCode", resp.StatusCode) - return nil, fmt.Errorf("unexpected status code from %s: %v", pod, resp.StatusCode) + loggerDefault.Error(nil, "Unexpected status code returned", "pod", existing.NamespacedName, "statusCode", resp.StatusCode) + return nil, fmt.Errorf("unexpected status code from %s: %v", existing.NamespacedName, resp.StatusCode) } parser := expfmt.TextParser{} diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index 8ce2956f..5edb2e77 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -48,8 +48,8 @@ func (s *Server) HandleRequestBody( // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. // This might be a security risk in the future where adapters not registered in the InferenceModel // are able to be requested by using their distinct name. - modelObj := s.datastore.FetchModelData(model) - if modelObj == nil { + modelObj, exist := s.datastore.ModelGet(model) + if !exist { return nil, fmt.Errorf("error finding a model object in InferenceModel for input %v", model) } if len(modelObj.Spec.TargetModels) > 0 { @@ -82,20 +82,29 @@ func (s *Server) HandleRequestBody( if err != nil { return nil, fmt.Errorf("failed to find target pod: %w", err) } + logger.V(logutil.DEFAULT).Info("Request handled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) + // Insert target endpoint to instruct Envoy to route requests to the specified target pod. + // Attach the port number + pool, err := s.datastore.PoolGet() + if err != nil { + return nil, err + } + endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) + reqCtx.Model = llmReq.Model reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel reqCtx.RequestSize = len(v.RequestBody.Body) - reqCtx.TargetPod = targetPod + reqCtx.TargetPod = targetPod.NamespacedName.String() + reqCtx.TargetEndpoint = endpoint - // Insert target endpoint to instruct Envoy to route requests to the specified target pod. headers := []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ Key: s.targetEndpointKey, - RawValue: []byte(targetPod.Address), + RawValue: []byte(endpoint), }, }, // We need to update the content length header if the body is mutated, see Envoy doc: @@ -134,7 +143,7 @@ func (s *Server) HandleRequestBody( Fields: map[string]*structpb.Value{ s.targetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: targetPod.Address, + StringValue: endpoint, }, }, }, diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index 6be747da..fe00ebeb 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -11,17 +11,15 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) -func NewServer(pp PodProvider, scheduler Scheduler, targetEndpointKey string, datastore ModelDataStore) *Server { +func NewServer(scheduler Scheduler, targetEndpointKey string, datastore backend.Datastore) *Server { return &Server{ scheduler: scheduler, - podProvider: pp, targetEndpointKey: targetEndpointKey, datastore: datastore, } @@ -30,26 +28,15 @@ func NewServer(pp PodProvider, scheduler Scheduler, targetEndpointKey string, da // Server implements the Envoy external processing server. // https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor.proto type Server struct { - scheduler Scheduler - podProvider PodProvider + scheduler Scheduler // The key of the header to specify the target pod address. This value needs to match Envoy // configuration. targetEndpointKey string - datastore ModelDataStore + datastore backend.Datastore } type Scheduler interface { - Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod backend.Pod, err error) -} - -// PodProvider is an interface to provide set of pods in the backend and information such as metrics. -type PodProvider interface { - GetPodMetrics(pod backend.Pod) (*backend.PodMetrics, bool) - UpdatePodMetrics(pod backend.Pod, pm *backend.PodMetrics) -} - -type ModelDataStore interface { - FetchModelData(modelName string) (returnModel *v1alpha1.InferenceModel) + Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod backend.PodMetrics, err error) } func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { @@ -140,7 +127,8 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { // RequestContext stores context information during the life time of an HTTP request. type RequestContext struct { - TargetPod backend.Pod + TargetPod string + TargetEndpoint string Model string ResolvedTargetModel string RequestReceivedTimestamp time.Time diff --git a/pkg/ext-proc/health.go b/pkg/ext-proc/health.go index 8b684d39..59aec348 100644 --- a/pkg/ext-proc/health.go +++ b/pkg/ext-proc/health.go @@ -13,11 +13,11 @@ import ( type healthServer struct { logger logr.Logger - datastore *backend.K8sDatastore + datastore backend.Datastore } func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) { - if !s.datastore.HasSynced() { + if !s.datastore.PoolHasSynced() { s.logger.V(logutil.VERBOSE).Info("gRPC health check not serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil } diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index ba593d7d..8e588673 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -59,10 +59,6 @@ var ( "poolNamespace", runserver.DefaultPoolNamespace, "Namespace of the InferencePool this Endpoint Picker is associated with.") - refreshPodsInterval = flag.Duration( - "refreshPodsInterval", - runserver.DefaultRefreshPodsInterval, - "interval to refresh pods") refreshMetricsInterval = flag.Duration( "refreshMetricsInterval", runserver.DefaultRefreshMetricsInterval, @@ -115,8 +111,6 @@ func run() error { }) setupLog.Info("Flags processed", "flags", flags) - datastore := backend.NewK8sDataStore() - // Init runtime. cfg, err := ctrl.GetConfig() if err != nil { @@ -131,17 +125,19 @@ func run() error { } // Setup runner. + datastore := backend.NewDatastore() + provider := backend.NewProvider(&vllm.PodMetricsClientImpl{}, datastore) serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, TargetEndpointKey: *targetEndpointKey, PoolName: *poolName, PoolNamespace: *poolNamespace, - RefreshPodsInterval: *refreshPodsInterval, RefreshMetricsInterval: *refreshMetricsInterval, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, Datastore: datastore, SecureServing: *secureServing, CertPath: *certPath, + Provider: provider, } if err := serverRunner.SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to setup ext-proc server") @@ -154,9 +150,7 @@ func run() error { } // Register ext-proc server. - if err := mgr.Add(serverRunner.AsRunnable( - ctrl.Log.WithName("ext-proc"), datastore, &vllm.PodMetricsClientImpl{}, - )); err != nil { + if err := mgr.Add(serverRunner.AsRunnable(ctrl.Log.WithName("ext-proc"))); err != nil { setupLog.Error(err, "Failed to register ext-proc server") return err } @@ -195,7 +189,7 @@ func initLogging(opts *zap.Options) { } // registerHealthServer adds the Health gRPC server as a Runnable to the given manager. -func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds *backend.K8sDatastore, port int) error { +func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds backend.Datastore, port int) error { srv := grpc.NewServer() healthPb.RegisterHealthServer(srv, &healthServer{ logger: logger, diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/ext-proc/scheduling/filter_test.go index ee1a8c33..9ed781c4 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/ext-proc/scheduling/filter_test.go @@ -6,6 +6,7 @@ import ( "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -40,7 +41,7 @@ func TestFilter(t *testing.T) { // model being active, and has low KV cache. input: []*backend.PodMetrics{ { - Pod: backend.Pod{Name: "pod1"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, Metrics: backend.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -52,7 +53,7 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{Name: "pod2"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, Metrics: backend.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -64,7 +65,7 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{Name: "pod3"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, Metrics: backend.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, @@ -77,7 +78,7 @@ func TestFilter(t *testing.T) { }, output: []*backend.PodMetrics{ { - Pod: backend.Pod{Name: "pod2"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, Metrics: backend.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -101,7 +102,7 @@ func TestFilter(t *testing.T) { // pod1 will be picked because it has capacity for the sheddable request. input: []*backend.PodMetrics{ { - Pod: backend.Pod{Name: "pod1"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, Metrics: backend.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -113,7 +114,7 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{Name: "pod2"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, Metrics: backend.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -125,7 +126,7 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{Name: "pod3"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, Metrics: backend.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, @@ -138,7 +139,7 @@ func TestFilter(t *testing.T) { }, output: []*backend.PodMetrics{ { - Pod: backend.Pod{Name: "pod1"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, Metrics: backend.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -163,7 +164,7 @@ func TestFilter(t *testing.T) { // dropped. input: []*backend.PodMetrics{ { - Pod: backend.Pod{Name: "pod1"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, Metrics: backend.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, @@ -175,7 +176,7 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{Name: "pod2"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, Metrics: backend.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.85, @@ -187,7 +188,7 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{Name: "pod3"}, + Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, Metrics: backend.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.85, diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index 16cf90b8..354bd39c 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -93,34 +93,29 @@ var ( } ) -func NewScheduler(pmp PodMetricsProvider) *Scheduler { +func NewScheduler(datastore backend.Datastore) *Scheduler { return &Scheduler{ - podMetricsProvider: pmp, - filter: defaultFilter, + datastore: datastore, + filter: defaultFilter, } } type Scheduler struct { - podMetricsProvider PodMetricsProvider - filter Filter -} - -// PodMetricsProvider is an interface to provide set of pods in the backend and information such as -// metrics. -type PodMetricsProvider interface { - AllPodMetrics() []*backend.PodMetrics + datastore backend.Datastore + filter Filter } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backend.Pod, err error) { +func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backend.PodMetrics, err error) { logger := log.FromContext(ctx).WithValues("request", req) - logger.V(logutil.VERBOSE).Info("Scheduling a request", "metrics", s.podMetricsProvider.AllPodMetrics()) - pods, err := s.filter.Filter(logger, req, s.podMetricsProvider.AllPodMetrics()) + podMetrics := s.datastore.PodGetAll() + logger.V(logutil.VERBOSE).Info("Scheduling a request", "metrics", podMetrics) + pods, err := s.filter.Filter(logger, req, podMetrics) if err != nil || len(pods) == 0 { - return backend.Pod{}, fmt.Errorf( + return backend.PodMetrics{}, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } logger.V(logutil.VERBOSE).Info("Selecting a random pod from the candidates", "candidatePods", pods) i := rand.Intn(len(pods)) - return pods[i].Pod, nil + return *pods[i], nil } diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index fb9741d2..073c30df 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -31,10 +31,10 @@ type ExtProcServerRunner struct { TargetEndpointKey string PoolName string PoolNamespace string - RefreshPodsInterval time.Duration RefreshMetricsInterval time.Duration RefreshPrometheusMetricsInterval time.Duration - Datastore *backend.K8sDatastore + Datastore backend.Datastore + Provider *backend.Provider SecureServing bool CertPath string } @@ -45,7 +45,6 @@ const ( DefaultTargetEndpointKey = "x-gateway-destination-endpoint" // default for --targetEndpointKey DefaultPoolName = "" // required but no default DefaultPoolNamespace = "default" // default for --poolNamespace - DefaultRefreshPodsInterval = 10 * time.Second // default for --refreshPodsInterval DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval DefaultRefreshPrometheusMetricsInterval = 5 * time.Second // default for --refreshPrometheusMetricsInterval DefaultSecureServing = true // default for --secureServing @@ -57,7 +56,6 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { TargetEndpointKey: DefaultTargetEndpointKey, PoolName: DefaultPoolName, PoolNamespace: DefaultPoolNamespace, - RefreshPodsInterval: DefaultRefreshPodsInterval, RefreshMetricsInterval: DefaultRefreshMetricsInterval, RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, SecureServing: DefaultSecureServing, @@ -107,15 +105,10 @@ func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { // AsRunnable returns a Runnable that can be used to start the ext-proc gRPC server. // The runnable implements LeaderElectionRunnable with leader election disabled. -func (r *ExtProcServerRunner) AsRunnable( - logger logr.Logger, - podDatastore *backend.K8sDatastore, - podMetricsClient backend.PodMetricsClient, -) manager.Runnable { +func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { // Initialize backend provider - pp := backend.NewProvider(podMetricsClient, podDatastore) - if err := pp.Init(logger.WithName("provider"), r.RefreshPodsInterval, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { + if err := r.Provider.Init(ctx, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { logger.Error(err, "Failed to initialize backend provider") return err } @@ -145,7 +138,7 @@ func (r *ExtProcServerRunner) AsRunnable( } extProcPb.RegisterExternalProcessorServer( srv, - handlers.NewServer(pp, scheduling.NewScheduler(pp), r.TargetEndpointKey, r.Datastore), + handlers.NewServer(scheduling.NewScheduler(r.Datastore), r.TargetEndpointKey, r.Datastore), ) // Forward to the gRPC runnable. diff --git a/pkg/ext-proc/server/runserver_test.go b/pkg/ext-proc/server/runserver_test.go index 1badb8fd..32af2cd8 100644 --- a/pkg/ext-proc/server/runserver_test.go +++ b/pkg/ext-proc/server/runserver_test.go @@ -11,7 +11,7 @@ import ( func TestRunnable(t *testing.T) { // Make sure AsRunnable() does not use leader election. - runner := server.NewDefaultExtProcServerRunner().AsRunnable(logutil.NewTestLogger(), nil, nil) + runner := server.NewDefaultExtProcServerRunner().AsRunnable(logutil.NewTestLogger()) r, ok := runner.(manager.LeaderElectionRunnable) if !ok { t.Fatal("runner is not LeaderElectionRunnable") diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index 9eca2edc..a48f0465 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -12,6 +13,7 @@ import ( "github.com/jhump/protoreflect/desc" uberzap "go.uber.org/zap" "google.golang.org/protobuf/proto" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" @@ -48,11 +50,11 @@ func run() error { } opts.BindFlags(flag.CommandLine) flag.Parse() - logger := zap.New(zap.UseFlagOptions(&opts), zap.RawZapOpts(uberzap.AddCaller())) + ctx := log.IntoContext(context.Background(), logger) if *localServer { - test.StartExtProc(logger, port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) + test.StartExtProc(ctx, port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) time.Sleep(time.Second) // wait until server is up logger.Info("Server started") } @@ -81,7 +83,7 @@ func run() error { func generateRequestFunc(logger logr.Logger) func(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { return func(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { numModels := *numFakePods * (*numModelsPerPod) - req := test.GenerateRequest(logger, modelName(int(callData.RequestNumber)%numModels)) + req := test.GenerateRequest(logger, "hello", modelName(int(callData.RequestNumber)%numModels)) data, err := proto.Marshal(req) if err != nil { logutil.Fatal(logger, err, "Failed to marshal request", "request", req) @@ -105,9 +107,7 @@ func fakeModels() map[string]*v1alpha1.InferenceModel { func fakePods() []*backend.PodMetrics { pms := make([]*backend.PodMetrics, 0, *numFakePods) for i := 0; i < *numFakePods; i++ { - metrics := fakeMetrics(i) - pod := test.FakePod(i) - pms = append(pms, &backend.PodMetrics{Pod: pod, Metrics: metrics}) + pms = append(pms, test.FakePodMetrics(i, fakeMetrics(i))) } return pms diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index cb99a36b..46affae9 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -1,6 +1,7 @@ package test import ( + "context" "encoding/json" "fmt" "net" @@ -10,36 +11,50 @@ import ( "github.com/go-logr/logr" "google.golang.org/grpc" "google.golang.org/grpc/reflection" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" ) func StartExtProc( - logger logr.Logger, + ctx context.Context, port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, pods []*backend.PodMetrics, models map[string]*v1alpha1.InferenceModel, ) *grpc.Server { - ps := make(backend.PodSet) - pms := make(map[backend.Pod]*backend.PodMetrics) + logger := log.FromContext(ctx) + pms := make(map[types.NamespacedName]*backend.PodMetrics) for _, pod := range pods { - ps[pod.Pod] = true - pms[pod.Pod] = pod + pms[pod.NamespacedName] = pod } pmc := &backend.FakePodMetricsClient{Res: pms} - pp := backend.NewProvider(pmc, backend.NewK8sDataStore(backend.WithPods(pods))) - if err := pp.Init(logger, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { + datastore := backend.NewDatastore() + for _, m := range models { + datastore.ModelSet(m) + } + for _, pm := range pods { + pod := utiltesting.MakePod(pm.NamespacedName.Name, pm.NamespacedName.Namespace). + ReadyCondition(). + IP(pm.Address). + Obj() + datastore.PodUpdateOrAddIfNotExist(&pod) + datastore.PodUpdateMetricsIfExist(pm.NamespacedName, &pm.Metrics) + } + pp := backend.NewProvider(pmc, datastore) + if err := pp.Init(ctx, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { logutil.Fatal(logger, err, "Failed to initialize") } - return startExtProc(logger, port, pp, models) + return startExtProc(logger, port, datastore) } // startExtProc starts an extProc server with fake pods. -func startExtProc(logger logr.Logger, port int, pp *backend.Provider, models map[string]*v1alpha1.InferenceModel) *grpc.Server { +func startExtProc(logger logr.Logger, port int, datastore backend.Datastore) *grpc.Server { lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { logutil.Fatal(logger, err, "Failed to listen", "port", port) @@ -47,7 +62,7 @@ func startExtProc(logger logr.Logger, port int, pp *backend.Provider, models map s := grpc.NewServer() - extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(pp, scheduling.NewScheduler(pp), "target-pod", &backend.FakeDataStore{Res: models})) + extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(scheduling.NewScheduler(datastore), "target-pod", datastore)) logger.Info("gRPC server starting", "port", port) reflection.Register(s) @@ -60,10 +75,10 @@ func startExtProc(logger logr.Logger, port int, pp *backend.Provider, models map return s } -func GenerateRequest(logger logr.Logger, model string) *extProcPb.ProcessingRequest { +func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.ProcessingRequest { j := map[string]interface{}{ "model": model, - "prompt": "hello", + "prompt": prompt, "max_tokens": 100, "temperature": 0, } @@ -80,11 +95,14 @@ func GenerateRequest(logger logr.Logger, model string) *extProcPb.ProcessingRequ return req } -func FakePod(index int) backend.Pod { +func FakePodMetrics(index int, metrics backend.Metrics) *backend.PodMetrics { address := fmt.Sprintf("address-%v", index) - pod := backend.Pod{ - Name: fmt.Sprintf("pod-%v", index), - Address: address, + pod := backend.PodMetrics{ + Pod: backend.Pod{ + NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index)}, + Address: address, + }, + Metrics: metrics, } - return pod + return &pod } diff --git a/pkg/ext-proc/util/testing/wrappers.go b/pkg/ext-proc/util/testing/wrappers.go new file mode 100644 index 00000000..f9005499 --- /dev/null +++ b/pkg/ext-proc/util/testing/wrappers.go @@ -0,0 +1,50 @@ +package testing + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PodWrapper wraps a Pod. +type PodWrapper struct { + corev1.Pod +} + +// MakePod creates a wrapper for a Pod. +func MakePod(podName, ns string) *PodWrapper { + return &PodWrapper{ + corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: ns, + }, + Spec: corev1.PodSpec{}, + Status: corev1.PodStatus{}, + }, + } +} + +// Labels sets the pod labels. +func (p *PodWrapper) Labels(labels map[string]string) *PodWrapper { + p.ObjectMeta.Labels = labels + return p +} + +// SetReadyCondition sets a PodReay=true condition. +func (p *PodWrapper) ReadyCondition() *PodWrapper { + p.Status.Conditions = []corev1.PodCondition{{ + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }} + return p +} + +func (p *PodWrapper) IP(ip string) *PodWrapper { + p.Status.PodIP = ip + return p +} + +// Obj returns the wrapped Pod. +func (p *PodWrapper) Obj() corev1.Pod { + return p.Pod +} diff --git a/pkg/manifests/vllm/deployment.yaml b/pkg/manifests/vllm/deployment.yaml index a54d99b3..51689c9f 100644 --- a/pkg/manifests/vllm/deployment.yaml +++ b/pkg/manifests/vllm/deployment.yaml @@ -26,7 +26,7 @@ spec: - "8000" - "--enable-lora" - "--max-loras" - - "2" + - "4" - "--max-cpu-loras" - "12" - "--lora-modules" diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index a99b6bd7..0e30ac69 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -17,11 +17,13 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8syaml "k8s.io/apimachinery/pkg/util/yaml" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -33,6 +35,7 @@ import ( runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" "sigs.k8s.io/yaml" ) @@ -61,36 +64,27 @@ func TestKubeInferenceModelRequest(t *testing.T) { }{ { name: "select lower queue and kv cache, no active lora", - req: extprocutils.GenerateRequest(logger, "my-model"), + req: extprocutils.GenerateRequest(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. pods: []*backend.PodMetrics{ - { - Pod: extprocutils.FakePod(0), - Metrics: backend.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.2, - }, - }, - { - Pod: extprocutils.FakePod(1), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.1, - }, - }, - { - Pod: extprocutils.FakePod(2), - Metrics: backend.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - }, - }, + extprocutils.FakePodMetrics(0, backend.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.2, + }), + extprocutils.FakePodMetrics(1, backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + }), + extprocutils.FakePodMetrics(2, backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + }), }, wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("address-1"), + RawValue: []byte("address-1:8000"), }, }, { @@ -104,58 +98,49 @@ func TestKubeInferenceModelRequest(t *testing.T) { Fields: map[string]*structpb.Value{ runserver.DefaultTargetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: "address-1", + StringValue: "address-1:8000", }, }, }, }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"hello\",\"temperature\":0}"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), wantErr: false, }, { name: "select active lora, low queue", - req: extprocutils.GenerateRequest(logger, "sql-lora"), + req: extprocutils.GenerateRequest(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. pods: []*backend.PodMetrics{ - { - Pod: extprocutils.FakePod(0), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, + extprocutils.FakePodMetrics(0, backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, }, - }, - { - Pod: extprocutils.FakePod(1), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.1, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg2": 1, - }, + }), + extprocutils.FakePodMetrics(1, backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, }, - }, - { - Pod: extprocutils.FakePod(2), - Metrics: backend.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - }, + }), + extprocutils.FakePodMetrics(2, backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, }, - }, + }), }, wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("address-1"), + RawValue: []byte("address-1:8000"), }, }, { @@ -169,59 +154,50 @@ func TestKubeInferenceModelRequest(t *testing.T) { Fields: map[string]*structpb.Value{ runserver.DefaultTargetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: "address-1", + StringValue: "address-1:8000", }, }, }, }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"hello\",\"temperature\":0}"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), wantErr: false, }, { name: "select no lora despite active model, avoid excessive queue size", - req: extprocutils.GenerateRequest(logger, "sql-lora"), + req: extprocutils.GenerateRequest(logger, "test3", "sql-lora"), // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 pods: []*backend.PodMetrics{ - { - Pod: extprocutils.FakePod(0), - Metrics: backend.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, + extprocutils.FakePodMetrics(0, backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, }, - }, - { - Pod: extprocutils.FakePod(1), - Metrics: backend.Metrics{ - WaitingQueueSize: 50, - KVCacheUsagePercent: 0.1, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg2": 1, - }, + }), + extprocutils.FakePodMetrics(1, backend.Metrics{ + WaitingQueueSize: 50, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, }, - }, - { - Pod: extprocutils.FakePod(2), - Metrics: backend.Metrics{ - WaitingQueueSize: 6, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - }, + }), + extprocutils.FakePodMetrics(2, backend.Metrics{ + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, }, - }, + }), }, wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("address-2"), + RawValue: []byte("address-2:8000"), }, }, { @@ -235,54 +211,45 @@ func TestKubeInferenceModelRequest(t *testing.T) { Fields: map[string]*structpb.Value{ runserver.DefaultTargetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: "address-2", + StringValue: "address-2:8000", }, }, }, }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"hello\",\"temperature\":0}"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), wantErr: false, }, { name: "noncritical and all models past threshold, shed request", - req: extprocutils.GenerateRequest(logger, "sql-lora-sheddable"), + req: extprocutils.GenerateRequest(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. pods: []*backend.PodMetrics{ - { - Pod: extprocutils.FakePod(0), - Metrics: backend.Metrics{ - WaitingQueueSize: 6, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - "sql-lora-1fdg3": 1, - }, + extprocutils.FakePodMetrics(0, backend.Metrics{ + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, }, - }, - { - Pod: extprocutils.FakePod(1), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.85, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, + }), + extprocutils.FakePodMetrics(1, backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, }, - }, - { - Pod: extprocutils.FakePod(2), - Metrics: backend.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.9, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, + }), + extprocutils.FakePodMetrics(2, backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, }, - }, + }), }, wantHeaders: []*configPb.HeaderValueOption{}, wantMetadata: &structpb.Struct{}, @@ -296,49 +263,40 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical, but one server has capacity, do not shed", - req: extprocutils.GenerateRequest(logger, "sql-lora-sheddable"), + req: extprocutils.GenerateRequest(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold pods: []*backend.PodMetrics{ - { - Pod: extprocutils.FakePod(0), - Metrics: backend.Metrics{ - WaitingQueueSize: 4, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - "sql-lora-1fdg3": 1, - }, + extprocutils.FakePodMetrics(0, backend.Metrics{ + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, }, - }, - { - Pod: extprocutils.FakePod(1), - Metrics: backend.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.85, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, + }), + extprocutils.FakePodMetrics(1, backend.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, }, - }, - { - Pod: extprocutils.FakePod(2), - Metrics: backend.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.9, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, + }), + extprocutils.FakePodMetrics(2, backend.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, }, - }, + }), }, wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ Key: runserver.DefaultTargetEndpointKey, - RawValue: []byte("address-0"), + RawValue: []byte("address-0:8000"), }, }, { @@ -352,18 +310,19 @@ func TestKubeInferenceModelRequest(t *testing.T) { Fields: map[string]*structpb.Value{ runserver.DefaultTargetEndpointKey: { Kind: &structpb.Value_StringValue{ - StringValue: "address-0", + StringValue: "address-0:8000", }, }, }, }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"hello\",\"temperature\":0}"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), wantErr: false, }, } // Set up global k8sclient and extproc server runner with test environment config - BeforeSuit() + cleanup := BeforeSuit(t) + defer cleanup() for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -405,27 +364,30 @@ func TestKubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - ps := make(backend.PodSet) - pms := make(map[backend.Pod]*backend.PodMetrics) - for _, pod := range pods { - ps[pod.Pod] = true - pms[pod.Pod] = pod +func setUpHermeticServer(podMetrics []*backend.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + pms := make(map[types.NamespacedName]*backend.PodMetrics) + for _, pm := range podMetrics { + pms[pm.NamespacedName] = pm } pmc := &backend.FakePodMetricsClient{Res: pms} serverCtx, stopServer := context.WithCancel(context.Background()) go func() { - if err := serverRunner.AsRunnable( - logger.WithName("ext-proc"), backend.NewK8sDataStore(backend.WithPods(pods)), pmc, - ).Start(serverCtx); err != nil { + serverRunner.Datastore.PodDeleteAll() + for _, pm := range podMetrics { + pod := utiltesting.MakePod(pm.NamespacedName.Name, pm.NamespacedName.Namespace). + ReadyCondition(). + IP(pm.Address). + Obj() + serverRunner.Datastore.PodUpdateOrAddIfNotExist(&pod) + serverRunner.Datastore.PodUpdateMetricsIfExist(pm.NamespacedName, &pm.Metrics) + } + serverRunner.Provider = backend.NewProvider(pmc, serverRunner.Datastore) + if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { logutil.Fatal(logger, err, "Failed to start ext-proc server") } }() - // Wait the reconciler to populate the datastore. - time.Sleep(10 * time.Second) - address := fmt.Sprintf("localhost:%v", port) // Create a grpc connection conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -442,11 +404,13 @@ func setUpHermeticServer(pods []*backend.PodMetrics) (client extProcPb.ExternalP cancel() conn.Close() stopServer() + // wait a little until the goroutines actually exit + time.Sleep(5 * time.Second) } } // Sets up a test environment and returns the runner struct -func BeforeSuit() { +func BeforeSuit(t *testing.T) func() { // Set up mock k8s API Client testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, @@ -477,7 +441,7 @@ func BeforeSuit() { serverRunner = runserver.NewDefaultExtProcServerRunner() // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" - serverRunner.Datastore = backend.NewK8sDataStore() + serverRunner.Datastore = backend.NewDatastore() serverRunner.SecureServing = false if err := serverRunner.SetupWithManager(mgr); err != nil { @@ -524,6 +488,16 @@ func BeforeSuit() { } } } + + assert.EventuallyWithT(t, func(t *assert.CollectT) { + _, modelExist := serverRunner.Datastore.ModelGet("my-model") + synced := serverRunner.Datastore.PoolHasSynced() && modelExist + assert.True(t, synced, "Timeout waiting for the pool and models to sync") + }, 10*time.Second, 10*time.Millisecond) + + return func() { + _ = testEnv.Stop() + } } func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { From 21d0c1372c37f3b14a125a87ce9611ab695a31d3 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 19 Feb 2025 13:30:27 +0200 Subject: [PATCH 031/260] fixed a typo - close a bash markdown (#364) Signed-off-by: Nir Rozenbaum --- site-src/guides/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index b9c38d87..34fff20c 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -24,6 +24,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v0.1.0/manifests.yaml + ``` ### Deploy InferenceModel From c998e50585a39e2304d82e639b233573b2de3d48 Mon Sep 17 00:00:00 2001 From: Tiger Xu / Zhonghu Xu Date: Wed, 19 Feb 2025 22:54:29 +0800 Subject: [PATCH 032/260] Added controller and datastore package (#363) * Added controller and datastore package * Fix lint --- pkg/ext-proc/backend/fake.go | 5 +- pkg/ext-proc/backend/provider.go | 9 +- pkg/ext-proc/backend/provider_test.go | 52 +++--- pkg/ext-proc/backend/vllm/metrics.go | 10 +- pkg/ext-proc/backend/vllm/metrics_test.go | 14 +- .../inferencemodel_reconciler.go | 5 +- .../inferencemodel_reconciler_test.go | 146 +++++++---------- .../inferencepool_reconciler.go | 5 +- .../inferencepool_reconciler_test.go | 25 +-- .../{backend => controller}/pod_reconciler.go | 5 +- .../pod_reconciler_test.go | 149 ++++++++---------- .../{backend => datastore}/datastore.go | 30 +++- .../{backend => datastore}/datastore_test.go | 2 +- pkg/ext-proc/{backend => datastore}/types.go | 4 +- pkg/ext-proc/handlers/request.go | 6 +- pkg/ext-proc/handlers/server.go | 8 +- pkg/ext-proc/health.go | 4 +- pkg/ext-proc/main.go | 5 +- pkg/ext-proc/scheduling/filter.go | 34 ++-- pkg/ext-proc/scheduling/filter_test.go | 130 +++++++-------- pkg/ext-proc/scheduling/scheduler.go | 14 +- pkg/ext-proc/server/runserver.go | 10 +- pkg/ext-proc/test/benchmark/benchmark.go | 10 +- pkg/ext-proc/test/utils.go | 15 +- test/integration/hermetic_test.go | 49 +++--- 25 files changed, 369 insertions(+), 377 deletions(-) rename pkg/ext-proc/{backend => controller}/inferencemodel_reconciler.go (95%) rename pkg/ext-proc/{backend => controller}/inferencemodel_reconciler_test.go (74%) rename pkg/ext-proc/{backend => controller}/inferencepool_reconciler.go (96%) rename pkg/ext-proc/{backend => controller}/inferencepool_reconciler_test.go (84%) rename pkg/ext-proc/{backend => controller}/pod_reconciler.go (95%) rename pkg/ext-proc/{backend => controller}/pod_reconciler_test.go (57%) rename pkg/ext-proc/{backend => datastore}/datastore.go (91%) rename pkg/ext-proc/{backend => datastore}/datastore_test.go (99%) rename pkg/ext-proc/{backend => datastore}/types.go (91%) diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index dfb520ef..2ddf2932 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -6,15 +6,16 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type FakePodMetricsClient struct { Err map[types.NamespacedName]error - Res map[types.NamespacedName]*PodMetrics + Res map[types.NamespacedName]*datastore.PodMetrics } -func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *PodMetrics) (*PodMetrics, error) { +func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *datastore.PodMetrics) (*datastore.PodMetrics, error) { if err, ok := f.Err[existing.NamespacedName]; ok { return nil, err } diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index bb575d19..103659db 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -9,6 +9,7 @@ import ( "github.com/go-logr/logr" "go.uber.org/multierr" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -17,7 +18,7 @@ const ( fetchMetricsTimeout = 5 * time.Second ) -func NewProvider(pmc PodMetricsClient, datastore Datastore) *Provider { +func NewProvider(pmc PodMetricsClient, datastore datastore.Datastore) *Provider { p := &Provider{ pmc: pmc, datastore: datastore, @@ -28,11 +29,11 @@ func NewProvider(pmc PodMetricsClient, datastore Datastore) *Provider { // Provider provides backend pods and information such as metrics. type Provider struct { pmc PodMetricsClient - datastore Datastore + datastore datastore.Datastore } type PodMetricsClient interface { - FetchMetrics(ctx context.Context, existing *PodMetrics) (*PodMetrics, error) + FetchMetrics(ctx context.Context, existing *datastore.PodMetrics) (*datastore.PodMetrics, error) } func (p *Provider) Init(ctx context.Context, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { @@ -100,7 +101,7 @@ func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { errCh := make(chan error) processOnePod := func(key, value any) bool { loggerTrace.Info("Pod and metric being processed", "pod", key, "metric", value) - existing := value.(*PodMetrics) + existing := value.(*datastore.PodMetrics) wg.Add(1) go func() { defer wg.Done() diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/ext-proc/backend/provider_test.go index 2aa2c213..95936f7e 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/ext-proc/backend/provider_test.go @@ -11,16 +11,17 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" ) var ( - pod1 = &PodMetrics{ - Pod: Pod{ + pod1 = &datastore.PodMetrics{ + Pod: datastore.Pod{ NamespacedName: types.NamespacedName{ Name: "pod1", }, }, - Metrics: Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -30,13 +31,13 @@ var ( }, }, } - pod2 = &PodMetrics{ - Pod: Pod{ + pod2 = &datastore.PodMetrics{ + Pod: datastore.Pod{ NamespacedName: types.NamespacedName{ Name: "pod2", }, }, - Metrics: Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -52,21 +53,19 @@ func TestProvider(t *testing.T) { tests := []struct { name string pmc PodMetricsClient - datastore Datastore - want []*PodMetrics + datastore datastore.Datastore + want []*datastore.PodMetrics }{ { name: "Probing metrics success", pmc: &FakePodMetricsClient{ - Res: map[types.NamespacedName]*PodMetrics{ + Res: map[types.NamespacedName]*datastore.PodMetrics{ pod1.NamespacedName: pod1, pod2.NamespacedName: pod2, }, }, - datastore: &datastore{ - pods: populateMap(pod1, pod2), - }, - want: []*PodMetrics{ + datastore: datastore.NewFakeDatastore(populateMap(pod1, pod2), nil, nil), + want: []*datastore.PodMetrics{ pod1, pod2, }, @@ -74,15 +73,13 @@ func TestProvider(t *testing.T) { { name: "Only pods in the datastore are probed", pmc: &FakePodMetricsClient{ - Res: map[types.NamespacedName]*PodMetrics{ + Res: map[types.NamespacedName]*datastore.PodMetrics{ pod1.NamespacedName: pod1, pod2.NamespacedName: pod2, }, }, - datastore: &datastore{ - pods: populateMap(pod1), - }, - want: []*PodMetrics{ + datastore: datastore.NewFakeDatastore(populateMap(pod1), nil, nil), + want: []*datastore.PodMetrics{ pod1, }, }, @@ -92,19 +89,18 @@ func TestProvider(t *testing.T) { Err: map[types.NamespacedName]error{ pod2.NamespacedName: errors.New("injected error"), }, - Res: map[types.NamespacedName]*PodMetrics{ + Res: map[types.NamespacedName]*datastore.PodMetrics{ pod1.NamespacedName: pod1, }, }, - datastore: &datastore{ - pods: populateMap(pod1, pod2), - }, - want: []*PodMetrics{ + datastore: datastore.NewFakeDatastore(populateMap(pod1, pod2), nil, nil), + + want: []*datastore.PodMetrics{ pod1, // Failed to fetch pod2 metrics so it remains the default values. { - Pod: Pod{NamespacedName: pod2.NamespacedName}, - Metrics: Metrics{ + Pod: datastore.Pod{NamespacedName: pod2.NamespacedName}, + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, MaxActiveModels: 0, @@ -122,7 +118,7 @@ func TestProvider(t *testing.T) { _ = p.Init(ctx, time.Millisecond, time.Millisecond) assert.EventuallyWithT(t, func(t *assert.CollectT) { metrics := test.datastore.PodGetAll() - diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(func(a, b *PodMetrics) bool { + diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(func(a, b *datastore.PodMetrics) bool { return a.String() < b.String() })) assert.Equal(t, "", diff, "Unexpected diff (+got/-want)") @@ -131,10 +127,10 @@ func TestProvider(t *testing.T) { } } -func populateMap(pods ...*PodMetrics) *sync.Map { +func populateMap(pods ...*datastore.PodMetrics) *sync.Map { newMap := &sync.Map{} for _, pod := range pods { - newMap.Store(pod.NamespacedName, &PodMetrics{Pod: Pod{NamespacedName: pod.NamespacedName, Address: pod.Address}}) + newMap.Store(pod.NamespacedName, &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: pod.NamespacedName, Address: pod.Address}}) } return newMap } diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index 3737425d..4785e484 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -14,7 +14,7 @@ import ( "github.com/prometheus/common/expfmt" "go.uber.org/multierr" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -38,8 +38,8 @@ type PodMetricsClientImpl struct{} // FetchMetrics fetches metrics from a given pod. func (p *PodMetricsClientImpl) FetchMetrics( ctx context.Context, - existing *backend.PodMetrics, -) (*backend.PodMetrics, error) { + existing *datastore.PodMetrics, +) (*datastore.PodMetrics, error) { logger := log.FromContext(ctx) loggerDefault := logger.V(logutil.DEFAULT) @@ -79,8 +79,8 @@ func (p *PodMetricsClientImpl) FetchMetrics( func promToPodMetrics( logger logr.Logger, metricFamilies map[string]*dto.MetricFamily, - existing *backend.PodMetrics, -) (*backend.PodMetrics, error) { + existing *datastore.PodMetrics, +) (*datastore.PodMetrics, error) { var errs error updated := existing.Clone() runningQueueSize, err := getLatestMetric(logger, metricFamilies, RunningQueueSizeMetricName) diff --git a/pkg/ext-proc/backend/vllm/metrics_test.go b/pkg/ext-proc/backend/vllm/metrics_test.go index 0a718cd7..23121ad5 100644 --- a/pkg/ext-proc/backend/vllm/metrics_test.go +++ b/pkg/ext-proc/backend/vllm/metrics_test.go @@ -7,7 +7,7 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -17,9 +17,9 @@ func TestPromToPodMetrics(t *testing.T) { testCases := []struct { name string metricFamilies map[string]*dto.MetricFamily - expectedMetrics *backend.Metrics + expectedMetrics *datastore.Metrics expectedErr error - initialPodMetrics *backend.PodMetrics + initialPodMetrics *datastore.PodMetrics }{ { name: "all metrics available", @@ -107,7 +107,7 @@ func TestPromToPodMetrics(t *testing.T) { }, }, }, - expectedMetrics: &backend.Metrics{ + expectedMetrics: &datastore.Metrics{ RunningQueueSize: 15, WaitingQueueSize: 25, KVCacheUsagePercent: 0.9, @@ -117,7 +117,7 @@ func TestPromToPodMetrics(t *testing.T) { }, MaxActiveModels: 2, }, - initialPodMetrics: &backend.PodMetrics{}, + initialPodMetrics: &datastore.PodMetrics{}, expectedErr: nil, }, { @@ -206,7 +206,7 @@ func TestPromToPodMetrics(t *testing.T) { }, }, }, - expectedMetrics: &backend.Metrics{ + expectedMetrics: &datastore.Metrics{ RunningQueueSize: 15, WaitingQueueSize: 25, KVCacheUsagePercent: 0.9, @@ -216,7 +216,7 @@ func TestPromToPodMetrics(t *testing.T) { }, MaxActiveModels: 0, }, - initialPodMetrics: &backend.PodMetrics{}, + initialPodMetrics: &datastore.PodMetrics{}, expectedErr: errors.New("strconv.Atoi: parsing '2a': invalid syntax"), }, } diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler.go b/pkg/ext-proc/controller/inferencemodel_reconciler.go similarity index 95% rename from pkg/ext-proc/backend/inferencemodel_reconciler.go rename to pkg/ext-proc/controller/inferencemodel_reconciler.go index 884e6b7e..a4622988 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler.go +++ b/pkg/ext-proc/controller/inferencemodel_reconciler.go @@ -1,4 +1,4 @@ -package backend +package controller import ( "context" @@ -12,6 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -19,7 +20,7 @@ type InferenceModelReconciler struct { client.Client Scheme *runtime.Scheme Record record.EventRecorder - Datastore Datastore + Datastore datastore.Datastore PoolNamespacedName types.NamespacedName } diff --git a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go b/pkg/ext-proc/controller/inferencemodel_reconciler_test.go similarity index 74% rename from pkg/ext-proc/backend/inferencemodel_reconciler_test.go rename to pkg/ext-proc/controller/inferencemodel_reconciler_test.go index 5afe3b5a..c3ebb646 100644 --- a/pkg/ext-proc/backend/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/controller/inferencemodel_reconciler_test.go @@ -1,4 +1,4 @@ -package backend +package controller import ( "context" @@ -13,6 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -51,58 +52,50 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { tests := []struct { name string - datastore *datastore + datastore datastore.Datastore incomingService *v1alpha1.InferenceModel wantInferenceModels *sync.Map }{ { name: "No Services registered; valid, new service incoming.", - datastore: &datastore{ - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, + datastore: datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, - models: &sync.Map{}, - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pool", + ResourceVersion: "Old and boring", + }, + }), + incomingService: infModel1, wantInferenceModels: populateServiceMap(infModel1), }, { name: "Removing existing service.", - datastore: &datastore{ - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, + datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, - models: populateServiceMap(infModel1), - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pool", + ResourceVersion: "Old and boring", + }, + }), incomingService: infModel1Modified, wantInferenceModels: populateServiceMap(), }, { name: "Unrelated service, do nothing.", - datastore: &datastore{ - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, + datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, - models: populateServiceMap(infModel1), - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pool", + ResourceVersion: "Old and boring", + }, + }), incomingService: &v1alpha1.InferenceModel{ Spec: v1alpha1.InferenceModelSpec{ ModelName: "fake model", @@ -116,33 +109,38 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { }, { name: "Add to existing", - datastore: &datastore{ - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, + datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, }, - models: populateServiceMap(infModel1), - }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pool", + ResourceVersion: "Old and boring", + }, + }), incomingService: infModel2, wantInferenceModels: populateServiceMap(infModel1, infModel2), }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { + pool, err := test.datastore.PoolGet() + if err != nil { + t.Fatalf("failed to get pool: %v", err) + } reconciler := &InferenceModelReconciler{ Datastore: test.datastore, - PoolNamespacedName: types.NamespacedName{Name: test.datastore.pool.Name}, + PoolNamespacedName: types.NamespacedName{Name: pool.Name}, } reconciler.updateDatastore(logger, test.incomingService) - if ok := mapsEqual(test.datastore.models, test.wantInferenceModels); !ok { - t.Error("Maps are not equal") - } + test.wantInferenceModels.Range(func(k, v any) bool { + _, exist := test.datastore.ModelGet(k.(string)) + if !exist { + t.Fatalf("failed to get model %s", k) + } + return true + }) }) } } @@ -156,12 +154,9 @@ func TestReconcile_ResourceNotFound(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() // Create a minimal datastore. - datastore := &datastore{ - models: &sync.Map{}, - pool: &v1alpha1.InferencePool{ - ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, - }, - } + datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, + }) // Create the reconciler. reconciler := &InferenceModelReconciler{ @@ -211,12 +206,9 @@ func TestReconcile_ModelMarkedForDeletion(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() // Create a minimal datastore. - datastore := &datastore{ - models: &sync.Map{}, - pool: &v1alpha1.InferencePool{ - ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, - }, - } + datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, + }) // Create the reconciler. reconciler := &InferenceModelReconciler{ @@ -268,12 +260,9 @@ func TestReconcile_ResourceExists(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() // Create a minimal datastore. - datastore := &datastore{ - models: &sync.Map{}, - pool: &v1alpha1.InferencePool{ - ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, - }, - } + datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, + }) // Create the reconciler. reconciler := &InferenceModelReconciler{ @@ -312,24 +301,3 @@ func populateServiceMap(services ...*v1alpha1.InferenceModel) *sync.Map { } return returnVal } - -func mapsEqual(map1, map2 *sync.Map) bool { - equal := true - - map1.Range(func(k, v any) bool { - if _, ok := map2.Load(k); !ok { - equal = false - return false - } - return true - }) - map2.Range(func(k, v any) bool { - if _, ok := map1.Load(k); !ok { - equal = false - return false - } - return true - }) - - return equal -} diff --git a/pkg/ext-proc/backend/inferencepool_reconciler.go b/pkg/ext-proc/controller/inferencepool_reconciler.go similarity index 96% rename from pkg/ext-proc/backend/inferencepool_reconciler.go rename to pkg/ext-proc/controller/inferencepool_reconciler.go index 6f52862e..5c9e4969 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler.go +++ b/pkg/ext-proc/controller/inferencepool_reconciler.go @@ -1,4 +1,4 @@ -package backend +package controller import ( "context" @@ -12,6 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -23,7 +24,7 @@ type InferencePoolReconciler struct { Scheme *runtime.Scheme Record record.EventRecorder PoolNamespacedName types.NamespacedName - Datastore Datastore + Datastore datastore.Datastore } func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { diff --git a/pkg/ext-proc/backend/inferencepool_reconciler_test.go b/pkg/ext-proc/controller/inferencepool_reconciler_test.go similarity index 84% rename from pkg/ext-proc/backend/inferencepool_reconciler_test.go rename to pkg/ext-proc/controller/inferencepool_reconciler_test.go index b6403489..ec2fdfe1 100644 --- a/pkg/ext-proc/backend/inferencepool_reconciler_test.go +++ b/pkg/ext-proc/controller/inferencepool_reconciler_test.go @@ -1,4 +1,4 @@ -package backend +package controller import ( "context" @@ -15,19 +15,20 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" ) var ( - selector_v1 = map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v1"} - selector_v2 = map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v2"} + selector_v1 = map[string]string{"app": "vllm_v1"} + selector_v2 = map[string]string{"app": "vllm_v2"} pool1 = &v1alpha1.InferencePool{ ObjectMeta: metav1.ObjectMeta{ Name: "pool1", Namespace: "pool1-ns", }, Spec: v1alpha1.InferencePoolSpec{ - Selector: selector_v1, + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v1"}, TargetPortNumber: 8080, }, } @@ -39,14 +40,14 @@ var ( } pods = []corev1.Pod{ // Two ready pods matching pool1 - utiltesting.MakePod("pod1", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).ReadyCondition().Obj(), - utiltesting.MakePod("pod2", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).ReadyCondition().Obj(), + utiltesting.MakePod("pod1", "pool1-ns").Labels(selector_v1).ReadyCondition().Obj(), + utiltesting.MakePod("pod2", "pool1-ns").Labels(selector_v1).ReadyCondition().Obj(), // A not ready pod matching pool1 - utiltesting.MakePod("pod3", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).Obj(), + utiltesting.MakePod("pod3", "pool1-ns").Labels(selector_v1).Obj(), // A pod not matching pool1 namespace - utiltesting.MakePod("pod4", "pool2-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v1)).ReadyCondition().Obj(), + utiltesting.MakePod("pod4", "pool2-ns").Labels(selector_v1).ReadyCondition().Obj(), // A ready pod matching pool1 with a new selector - utiltesting.MakePod("pod5", "pool1-ns").Labels(stripLabelKeyAliasFromLabelMap(selector_v2)).ReadyCondition().Obj(), + utiltesting.MakePod("pod5", "pool1-ns").Labels(selector_v2).ReadyCondition().Obj(), } ) @@ -74,7 +75,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { req := ctrl.Request{NamespacedName: namespacedName} ctx := context.Background() - datastore := NewDatastore() + datastore := datastore.NewDatastore() inferencePoolReconciler := &InferencePoolReconciler{PoolNamespacedName: namespacedName, Client: fakeClient, Datastore: datastore} // Step 1: Inception, only ready pods matching pool1 are added to the store. @@ -98,7 +99,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { t.Errorf("Unexpected pool get error: %v", err) } - newPool1.Spec.Selector = selector_v2 + newPool1.Spec.Selector = map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v2"} if err := fakeClient.Update(ctx, newPool1, &client.UpdateOptions{}); err != nil { t.Errorf("Unexpected pool update error: %v", err) } @@ -140,7 +141,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { } } -func diffPool(datastore Datastore, wantPool *v1alpha1.InferencePool, wantPods []string) string { +func diffPool(datastore datastore.Datastore, wantPool *v1alpha1.InferencePool, wantPods []string) string { gotPool, _ := datastore.PoolGet() if diff := cmp.Diff(wantPool, gotPool); diff != "" { return diff diff --git a/pkg/ext-proc/backend/pod_reconciler.go b/pkg/ext-proc/controller/pod_reconciler.go similarity index 95% rename from pkg/ext-proc/backend/pod_reconciler.go rename to pkg/ext-proc/controller/pod_reconciler.go index 8705ce83..209d2ca7 100644 --- a/pkg/ext-proc/backend/pod_reconciler.go +++ b/pkg/ext-proc/controller/pod_reconciler.go @@ -1,4 +1,4 @@ -package backend +package controller import ( "context" @@ -12,12 +12,13 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type PodReconciler struct { client.Client - Datastore Datastore + Datastore datastore.Datastore Scheme *runtime.Scheme Record record.EventRecorder } diff --git a/pkg/ext-proc/backend/pod_reconciler_test.go b/pkg/ext-proc/controller/pod_reconciler_test.go similarity index 57% rename from pkg/ext-proc/backend/pod_reconciler_test.go rename to pkg/ext-proc/controller/pod_reconciler_test.go index cc7381f6..b146745a 100644 --- a/pkg/ext-proc/backend/pod_reconciler_test.go +++ b/pkg/ext-proc/controller/pod_reconciler_test.go @@ -1,7 +1,8 @@ -package backend +package controller import ( "context" + "sync" "testing" "github.com/google/go-cmp/cmp" @@ -15,37 +16,35 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" ) var ( - basePod1 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1"}} - basePod2 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2"}} - basePod3 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3"}} - basePod11 = &PodMetrics{Pod: Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11"}} + basePod1 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1"}} + basePod2 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2"}} + basePod3 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3"}} + basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11"}} ) func TestUpdateDatastore_PodReconciler(t *testing.T) { now := metav1.Now() tests := []struct { name string - datastore Datastore + datastore datastore.Datastore incomingPod *corev1.Pod - wantPods []Pod + wantPods []datastore.Pod req *ctrl.Request }{ { name: "Add new pod", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: basePod3.NamespacedName.Name, @@ -63,21 +62,18 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []Pod{basePod1.Pod, basePod2.Pod, basePod3.Pod}, + wantPods: []datastore.Pod{basePod1.Pod, basePod2.Pod, basePod3.Pod}, }, { name: "Update pod1 address", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: basePod11.NamespacedName.Name, @@ -95,21 +91,18 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []Pod{basePod11.Pod, basePod2.Pod}, + wantPods: []datastore.Pod{basePod11.Pod, basePod2.Pod}, }, { name: "Delete pod with DeletionTimestamp", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "pod1", @@ -128,37 +121,31 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []Pod{basePod2.Pod}, + wantPods: []datastore.Pod{basePod2.Pod}, }, { name: "Delete notfound pod", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), req: &ctrl.Request{NamespacedName: types.NamespacedName{Name: "pod1"}}, - wantPods: []Pod{basePod2.Pod}, + wantPods: []datastore.Pod{basePod2.Pod}, }, { name: "New pod, not ready, valid selector", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "pod3", @@ -175,21 +162,18 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []Pod{basePod1.Pod, basePod2.Pod}, + wantPods: []datastore.Pod{basePod1.Pod, basePod2.Pod}, }, { name: "Remove pod that does not match selector", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "pod1", @@ -206,21 +190,18 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []Pod{basePod2.Pod}, + wantPods: []datastore.Pod{basePod2.Pod}, }, { name: "Remove pod that is not ready", - datastore: &datastore{ - pods: populateMap(basePod1, basePod2), - pool: &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ - "some-key": "some-val", - }, + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ + Spec: v1alpha1.InferencePoolSpec{ + TargetPortNumber: int32(8000), + Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + "some-key": "some-val", }, }, - }, + }), incomingPod: &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "pod1", @@ -237,7 +218,7 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }, - wantPods: []Pod{basePod2.Pod}, + wantPods: []datastore.Pod{basePod2.Pod}, }, } for _, test := range tests { @@ -263,17 +244,25 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { t.Errorf("Unexpected InferencePool reconcile error: %v", err) } - var gotPods []Pod + var gotPods []datastore.Pod test.datastore.PodRange(func(k, v any) bool { - pod := v.(*PodMetrics) + pod := v.(*datastore.PodMetrics) if v != nil { gotPods = append(gotPods, pod.Pod) } return true }) - if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b Pod) bool { return a.NamespacedName.String() < b.NamespacedName.String() })) { + if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b datastore.Pod) bool { return a.NamespacedName.String() < b.NamespacedName.String() })) { t.Errorf("got (%v) != want (%v);", gotPods, test.wantPods) } }) } } + +func populateMap(pods ...*datastore.PodMetrics) *sync.Map { + newMap := &sync.Map{} + for _, pod := range pods { + newMap.Store(pod.NamespacedName, &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: pod.NamespacedName, Address: pod.Address}}) + } + return newMap +} diff --git a/pkg/ext-proc/backend/datastore.go b/pkg/ext-proc/datastore/datastore.go similarity index 91% rename from pkg/ext-proc/backend/datastore.go rename to pkg/ext-proc/datastore/datastore.go index 6b8483d3..f85f9014 100644 --- a/pkg/ext-proc/backend/datastore.go +++ b/pkg/ext-proc/datastore/datastore.go @@ -1,4 +1,4 @@ -package backend +package datastore import ( "context" @@ -52,6 +52,21 @@ func NewDatastore() Datastore { return store } +// Used for test only +func NewFakeDatastore(pods, models *sync.Map, pool *v1alpha1.InferencePool) Datastore { + store := NewDatastore() + if pods != nil { + store.(*datastore).pods = pods + } + if models != nil { + store.(*datastore).models = models + } + if pool != nil { + store.(*datastore).pool = pool + } + return store +} + type datastore struct { // poolMu is used to synchronize access to the inferencePool. poolMu sync.RWMutex @@ -249,3 +264,16 @@ func IsCritical(model *v1alpha1.InferenceModel) bool { } return false } + +// TODO: move out to share with pod_reconciler.go +func podIsReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + if condition.Status == corev1.ConditionTrue { + return true + } + break + } + } + return false +} diff --git a/pkg/ext-proc/backend/datastore_test.go b/pkg/ext-proc/datastore/datastore_test.go similarity index 99% rename from pkg/ext-proc/backend/datastore_test.go rename to pkg/ext-proc/datastore/datastore_test.go index b44de0a5..6c5874df 100644 --- a/pkg/ext-proc/backend/datastore_test.go +++ b/pkg/ext-proc/datastore/datastore_test.go @@ -1,4 +1,4 @@ -package backend +package datastore import ( "testing" diff --git a/pkg/ext-proc/backend/types.go b/pkg/ext-proc/datastore/types.go similarity index 91% rename from pkg/ext-proc/backend/types.go rename to pkg/ext-proc/datastore/types.go index 0e02fb09..221c6630 100644 --- a/pkg/ext-proc/backend/types.go +++ b/pkg/ext-proc/datastore/types.go @@ -1,5 +1,5 @@ -// Package backend is a library to interact with backend model servers such as probing metrics. -package backend +// Package datastore is a library to interact with backend model servers such as probing metrics. +package datastore import ( "fmt" diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index 5edb2e77..b3ef08e0 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -11,7 +11,7 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -53,7 +53,7 @@ func (s *Server) HandleRequestBody( return nil, fmt.Errorf("error finding a model object in InferenceModel for input %v", model) } if len(modelObj.Spec.TargetModels) > 0 { - modelName = backend.RandomWeightedDraw(logger, modelObj, 0) + modelName = datastore.RandomWeightedDraw(logger, modelObj, 0) if modelName == "" { return nil, fmt.Errorf("error getting target model name for model %v", modelObj.Name) } @@ -61,7 +61,7 @@ func (s *Server) HandleRequestBody( llmReq := &scheduling.LLMRequest{ Model: model, ResolvedTargetModel: modelName, - Critical: backend.IsCritical(modelObj), + Critical: datastore.IsCritical(modelObj), } loggerVerbose.Info("LLM request assembled", "request", llmReq) diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index fe00ebeb..05de0c42 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -11,13 +11,13 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) -func NewServer(scheduler Scheduler, targetEndpointKey string, datastore backend.Datastore) *Server { +func NewServer(scheduler Scheduler, targetEndpointKey string, datastore datastore.Datastore) *Server { return &Server{ scheduler: scheduler, targetEndpointKey: targetEndpointKey, @@ -32,11 +32,11 @@ type Server struct { // The key of the header to specify the target pod address. This value needs to match Envoy // configuration. targetEndpointKey string - datastore backend.Datastore + datastore datastore.Datastore } type Scheduler interface { - Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod backend.PodMetrics, err error) + Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod datastore.PodMetrics, err error) } func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { diff --git a/pkg/ext-proc/health.go b/pkg/ext-proc/health.go index 59aec348..525440cb 100644 --- a/pkg/ext-proc/health.go +++ b/pkg/ext-proc/health.go @@ -7,13 +7,13 @@ import ( "google.golang.org/grpc/codes" healthPb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/status" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type healthServer struct { logger logr.Logger - datastore backend.Datastore + datastore datastore.Datastore } func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) { diff --git a/pkg/ext-proc/main.go b/pkg/ext-proc/main.go index 8e588673..d43f2c57 100644 --- a/pkg/ext-proc/main.go +++ b/pkg/ext-proc/main.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" @@ -125,7 +126,7 @@ func run() error { } // Setup runner. - datastore := backend.NewDatastore() + datastore := datastore.NewDatastore() provider := backend.NewProvider(&vllm.PodMetricsClientImpl{}, datastore) serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, @@ -189,7 +190,7 @@ func initLogging(opts *zap.Options) { } // registerHealthServer adds the Health gRPC server as a Runnable to the given manager. -func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds backend.Datastore, port int) error { +func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds datastore.Datastore, port int) error { srv := grpc.NewServer() healthPb.RegisterHealthServer(srv, &healthServer{ logger: logger, diff --git a/pkg/ext-proc/scheduling/filter.go b/pkg/ext-proc/scheduling/filter.go index e028c59a..4d53e720 100644 --- a/pkg/ext-proc/scheduling/filter.go +++ b/pkg/ext-proc/scheduling/filter.go @@ -5,13 +5,13 @@ import ( "math" "github.com/go-logr/logr" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) type Filter interface { Name() string - Filter(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) + Filter(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) } // filter applies current filterFunc, and then recursively applies next filters depending success or @@ -41,7 +41,7 @@ func (f *filter) Name() string { return f.name } -func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { +func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { loggerTrace := logger.V(logutil.TRACE) loggerTrace.Info("Running a filter", "name", f.Name(), "podCount", len(pods)) @@ -74,12 +74,12 @@ func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []*backend.Pod } // filterFunc filters a set of input pods to a subset. -type filterFunc func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) +type filterFunc func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) // toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. func toFilterFunc(pp podPredicate) filterFunc { - return func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { - filtered := []*backend.PodMetrics{} + return func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { + filtered := []*datastore.PodMetrics{} for _, pod := range pods { pass := pp(req, pod) if pass { @@ -100,10 +100,10 @@ func toFilterFunc(pp podPredicate) filterFunc { // the least one as it gives more choices for the next filter, which on aggregate gave better // results. // TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { +func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { min := math.MaxInt max := 0 - filtered := []*backend.PodMetrics{} + filtered := []*datastore.PodMetrics{} for _, pod := range pods { if pod.WaitingQueueSize <= min { @@ -122,7 +122,7 @@ func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []*backend return filtered, nil } -func lowQueueingPodPredicate(_ *LLMRequest, pod *backend.PodMetrics) bool { +func lowQueueingPodPredicate(_ *LLMRequest, pod *datastore.PodMetrics) bool { return pod.WaitingQueueSize < queueingThresholdLoRA } @@ -132,10 +132,10 @@ func lowQueueingPodPredicate(_ *LLMRequest, pod *backend.PodMetrics) bool { // should consider them all instead of the absolute minimum one. This worked better than picking the // least one as it gives more choices for the next filter, which on aggregate gave better results. // TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { +func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { min := math.MaxFloat64 var max float64 = 0 - filtered := []*backend.PodMetrics{} + filtered := []*datastore.PodMetrics{} for _, pod := range pods { if pod.KVCacheUsagePercent <= min { @@ -155,35 +155,35 @@ func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []*backend } // podPredicate is a filter function to check whether a pod is desired. -type podPredicate func(req *LLMRequest, pod *backend.PodMetrics) bool +type podPredicate func(req *LLMRequest, pod *datastore.PodMetrics) bool // We consider serving an adapter low cost it the adapter is active in the model server, or the // model server has room to load the adapter. The lowLoRACostPredicate ensures weak affinity by // spreading the load of a LoRA adapter across multiple pods, avoiding "pinning" all requests to // a single pod. This gave good performance in our initial benchmarking results in the scenario // where # of lora slots > # of lora adapters. -func lowLoRACostPredicate(req *LLMRequest, pod *backend.PodMetrics) bool { +func lowLoRACostPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { _, ok := pod.ActiveModels[req.ResolvedTargetModel] return ok || len(pod.ActiveModels) < pod.MaxActiveModels } // loRAAffinityPredicate is a filter function to check whether a pod has affinity to the lora requested. -func loRAAffinityPredicate(req *LLMRequest, pod *backend.PodMetrics) bool { +func loRAAffinityPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { _, ok := pod.ActiveModels[req.ResolvedTargetModel] return ok } // canAcceptNewLoraPredicate is a filter function to check whether a pod has room to load the adapter. -func canAcceptNewLoraPredicate(req *LLMRequest, pod *backend.PodMetrics) bool { +func canAcceptNewLoraPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { return len(pod.ActiveModels) < pod.MaxActiveModels } -func criticalRequestPredicate(req *LLMRequest, pod *backend.PodMetrics) bool { +func criticalRequestPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { return req.Critical } func noQueueAndLessThanKVCacheThresholdPredicate(queueThreshold int, kvCacheThreshold float64) podPredicate { - return func(req *LLMRequest, pod *backend.PodMetrics) bool { + return func(req *LLMRequest, pod *datastore.PodMetrics) bool { return pod.WaitingQueueSize <= queueThreshold && pod.KVCacheUsagePercent <= kvCacheThreshold } } diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/ext-proc/scheduling/filter_test.go index 9ed781c4..b2ae4b89 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/ext-proc/scheduling/filter_test.go @@ -7,7 +7,7 @@ import ( "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -17,14 +17,14 @@ func TestFilter(t *testing.T) { tests := []struct { name string req *LLMRequest - input []*backend.PodMetrics - output []*backend.PodMetrics + input []*datastore.PodMetrics + output []*datastore.PodMetrics err bool filter *filter }{ { name: "simple filter without successor, failure", - filter: &filter{filter: func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { + filter: &filter{filter: func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { return nil, errors.New("filter error") }}, err: true, @@ -39,10 +39,10 @@ func TestFilter(t *testing.T) { }, // pod2 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -53,8 +53,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -65,8 +65,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -76,10 +76,10 @@ func TestFilter(t *testing.T) { }, }, }, - output: []*backend.PodMetrics{ + output: []*datastore.PodMetrics{ { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -100,10 +100,10 @@ func TestFilter(t *testing.T) { Critical: false, }, // pod1 will be picked because it has capacity for the sheddable request. - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -114,8 +114,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -126,8 +126,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -137,10 +137,10 @@ func TestFilter(t *testing.T) { }, }, }, - output: []*backend.PodMetrics{ + output: []*datastore.PodMetrics{ { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -162,10 +162,10 @@ func TestFilter(t *testing.T) { }, // All pods have higher KV cache thant the threshold, so the sheddable request will be // dropped. - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, MaxActiveModels: 2, @@ -176,8 +176,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.85, MaxActiveModels: 2, @@ -188,8 +188,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: backend.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: backend.Metrics{ + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, + Metrics: datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.85, MaxActiveModels: 2, @@ -199,7 +199,7 @@ func TestFilter(t *testing.T) { }, }, }, - output: []*backend.PodMetrics{}, + output: []*datastore.PodMetrics{}, err: true, }, } @@ -225,44 +225,44 @@ func TestFilterFunc(t *testing.T) { name string f filterFunc req *LLMRequest - input []*backend.PodMetrics - output []*backend.PodMetrics + input []*datastore.PodMetrics + output []*datastore.PodMetrics err bool }{ { name: "least queuing empty input", f: leastQueuingFilterFunc, - input: []*backend.PodMetrics{}, - output: []*backend.PodMetrics{}, + input: []*datastore.PodMetrics{}, + output: []*datastore.PodMetrics{}, }, { name: "least queuing", f: leastQueuingFilterFunc, - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 0, }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 3, }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 10, }, }, }, - output: []*backend.PodMetrics{ + output: []*datastore.PodMetrics{ { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 0, }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 3, }, }, @@ -271,37 +271,37 @@ func TestFilterFunc(t *testing.T) { { name: "least kv cache empty input", f: leastKVCacheFilterFunc, - input: []*backend.PodMetrics{}, - output: []*backend.PodMetrics{}, + input: []*datastore.PodMetrics{}, + output: []*datastore.PodMetrics{}, }, { name: "least kv cache", f: leastKVCacheFilterFunc, - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ KVCacheUsagePercent: 0, }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ KVCacheUsagePercent: 0.3, }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ KVCacheUsagePercent: 1.0, }, }, }, - output: []*backend.PodMetrics{ + output: []*datastore.PodMetrics{ { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ KVCacheUsagePercent: 0, }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ KVCacheUsagePercent: 0.3, }, }, @@ -310,32 +310,32 @@ func TestFilterFunc(t *testing.T) { { name: "noQueueAndLessThanKVCacheThresholdPredicate", f: toFilterFunc(noQueueAndLessThanKVCacheThresholdPredicate(0, 0.8)), - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ { // This pod should be returned. - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, }, { // Queue is non zero, despite low kv cache, should not return. - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.3, }, }, { // High kv cache despite zero queue, should not return - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 1.0, }, }, }, - output: []*backend.PodMetrics{ + output: []*datastore.PodMetrics{ { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, @@ -349,10 +349,10 @@ func TestFilterFunc(t *testing.T) { Model: "model", ResolvedTargetModel: "model", }, - input: []*backend.PodMetrics{ + input: []*datastore.PodMetrics{ // ActiveModels include input model, should be returned. { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "model": 1, @@ -361,7 +361,7 @@ func TestFilterFunc(t *testing.T) { }, // Input model is not active, however the server has room to load another adapter. { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "another-model": 1, @@ -370,7 +370,7 @@ func TestFilterFunc(t *testing.T) { }, // Input is not active, and the server has reached max active models. { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "foo": 1, @@ -379,9 +379,9 @@ func TestFilterFunc(t *testing.T) { }, }, }, - output: []*backend.PodMetrics{ + output: []*datastore.PodMetrics{ { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "model": 1, @@ -389,7 +389,7 @@ func TestFilterFunc(t *testing.T) { }, }, { - Metrics: backend.Metrics{ + Metrics: datastore.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "another-model": 1, diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index 354bd39c..1e56fee3 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -10,7 +10,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -84,16 +84,16 @@ var ( // request to make room for critical requests. nextOnFailure: &filter{ name: "drop request", - filter: func(logger logr.Logger, req *LLMRequest, pods []*backend.PodMetrics) ([]*backend.PodMetrics, error) { + filter: func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { logger.V(logutil.DEFAULT).Info("Request dropped", "request", req) - return []*backend.PodMetrics{}, status.Errorf( + return []*datastore.PodMetrics{}, status.Errorf( codes.ResourceExhausted, "dropping request due to limited backend resources") }, }, } ) -func NewScheduler(datastore backend.Datastore) *Scheduler { +func NewScheduler(datastore datastore.Datastore) *Scheduler { return &Scheduler{ datastore: datastore, filter: defaultFilter, @@ -101,18 +101,18 @@ func NewScheduler(datastore backend.Datastore) *Scheduler { } type Scheduler struct { - datastore backend.Datastore + datastore datastore.Datastore filter Filter } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backend.PodMetrics, err error) { +func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod datastore.PodMetrics, err error) { logger := log.FromContext(ctx).WithValues("request", req) podMetrics := s.datastore.PodGetAll() logger.V(logutil.VERBOSE).Info("Scheduling a request", "metrics", podMetrics) pods, err := s.filter.Filter(logger, req, podMetrics) if err != nil || len(pods) == 0 { - return backend.PodMetrics{}, fmt.Errorf( + return datastore.PodMetrics{}, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } logger.V(logutil.VERBOSE).Info("Selecting a random pod from the candidates", "candidatePods", pods) diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index 073c30df..7b0209a6 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -20,6 +20,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/controller" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" @@ -33,7 +35,7 @@ type ExtProcServerRunner struct { PoolNamespace string RefreshMetricsInterval time.Duration RefreshPrometheusMetricsInterval time.Duration - Datastore backend.Datastore + Datastore datastore.Datastore Provider *backend.Provider SecureServing bool CertPath string @@ -66,7 +68,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { // SetupWithManager sets up the runner with the given manager. func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { // Create the controllers and register them with the manager - if err := (&backend.InferencePoolReconciler{ + if err := (&controller.InferencePoolReconciler{ Datastore: r.Datastore, Scheme: mgr.GetScheme(), Client: mgr.GetClient(), @@ -79,7 +81,7 @@ func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { return fmt.Errorf("failed setting up InferencePoolReconciler: %w", err) } - if err := (&backend.InferenceModelReconciler{ + if err := (&controller.InferenceModelReconciler{ Datastore: r.Datastore, Scheme: mgr.GetScheme(), Client: mgr.GetClient(), @@ -92,7 +94,7 @@ func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { return fmt.Errorf("failed setting up InferenceModelReconciler: %w", err) } - if err := (&backend.PodReconciler{ + if err := (&controller.PodReconciler{ Datastore: r.Datastore, Scheme: mgr.GetScheme(), Client: mgr.GetClient(), diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index a48f0465..3820998d 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -16,7 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" @@ -104,8 +104,8 @@ func fakeModels() map[string]*v1alpha1.InferenceModel { return models } -func fakePods() []*backend.PodMetrics { - pms := make([]*backend.PodMetrics, 0, *numFakePods) +func fakePods() []*datastore.PodMetrics { + pms := make([]*datastore.PodMetrics, 0, *numFakePods) for i := 0; i < *numFakePods; i++ { pms = append(pms, test.FakePodMetrics(i, fakeMetrics(i))) } @@ -114,8 +114,8 @@ func fakePods() []*backend.PodMetrics { } // fakeMetrics adds numModelsPerPod number of adapters to the pod metrics. -func fakeMetrics(podNumber int) backend.Metrics { - metrics := backend.Metrics{ +func fakeMetrics(podNumber int) datastore.Metrics { + metrics := datastore.Metrics{ ActiveModels: make(map[string]int), } for i := 0; i < *numModelsPerPod; i++ { diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index 46affae9..a2d833e0 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -15,6 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" @@ -25,16 +26,16 @@ func StartExtProc( ctx context.Context, port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, - pods []*backend.PodMetrics, + pods []*datastore.PodMetrics, models map[string]*v1alpha1.InferenceModel, ) *grpc.Server { logger := log.FromContext(ctx) - pms := make(map[types.NamespacedName]*backend.PodMetrics) + pms := make(map[types.NamespacedName]*datastore.PodMetrics) for _, pod := range pods { pms[pod.NamespacedName] = pod } pmc := &backend.FakePodMetricsClient{Res: pms} - datastore := backend.NewDatastore() + datastore := datastore.NewDatastore() for _, m := range models { datastore.ModelSet(m) } @@ -54,7 +55,7 @@ func StartExtProc( } // startExtProc starts an extProc server with fake pods. -func startExtProc(logger logr.Logger, port int, datastore backend.Datastore) *grpc.Server { +func startExtProc(logger logr.Logger, port int, datastore datastore.Datastore) *grpc.Server { lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { logutil.Fatal(logger, err, "Failed to listen", "port", port) @@ -95,10 +96,10 @@ func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.Proces return req } -func FakePodMetrics(index int, metrics backend.Metrics) *backend.PodMetrics { +func FakePodMetrics(index int, metrics datastore.Metrics) *datastore.PodMetrics { address := fmt.Sprintf("address-%v", index) - pod := backend.PodMetrics{ - Pod: backend.Pod{ + pod := datastore.PodMetrics{ + Pod: datastore.Pod{ NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index)}, Address: address, }, diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 0e30ac69..89fc02d7 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -32,6 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" @@ -55,7 +56,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { tests := []struct { name string req *extProcPb.ProcessingRequest - pods []*backend.PodMetrics + pods []*datastore.PodMetrics wantHeaders []*configPb.HeaderValueOption wantMetadata *structpb.Struct wantBody []byte @@ -66,16 +67,16 @@ func TestKubeInferenceModelRequest(t *testing.T) { name: "select lower queue and kv cache, no active lora", req: extprocutils.GenerateRequest(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. - pods: []*backend.PodMetrics{ - extprocutils.FakePodMetrics(0, backend.Metrics{ + pods: []*datastore.PodMetrics{ + extprocutils.FakePodMetrics(0, datastore.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.2, }), - extprocutils.FakePodMetrics(1, backend.Metrics{ + extprocutils.FakePodMetrics(1, datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.1, }), - extprocutils.FakePodMetrics(2, backend.Metrics{ + extprocutils.FakePodMetrics(2, datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, }), @@ -111,8 +112,8 @@ func TestKubeInferenceModelRequest(t *testing.T) { req: extprocutils.GenerateRequest(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. - pods: []*backend.PodMetrics{ - extprocutils.FakePodMetrics(0, backend.Metrics{ + pods: []*datastore.PodMetrics{ + extprocutils.FakePodMetrics(0, datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -120,7 +121,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "bar": 1, }, }), - extprocutils.FakePodMetrics(1, backend.Metrics{ + extprocutils.FakePodMetrics(1, datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.1, ActiveModels: map[string]int{ @@ -128,7 +129,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "sql-lora-1fdg2": 1, }, }), - extprocutils.FakePodMetrics(2, backend.Metrics{ + extprocutils.FakePodMetrics(2, datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -168,8 +169,8 @@ func TestKubeInferenceModelRequest(t *testing.T) { // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 - pods: []*backend.PodMetrics{ - extprocutils.FakePodMetrics(0, backend.Metrics{ + pods: []*datastore.PodMetrics{ + extprocutils.FakePodMetrics(0, datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -177,7 +178,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "bar": 1, }, }), - extprocutils.FakePodMetrics(1, backend.Metrics{ + extprocutils.FakePodMetrics(1, datastore.Metrics{ WaitingQueueSize: 50, KVCacheUsagePercent: 0.1, ActiveModels: map[string]int{ @@ -185,7 +186,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "sql-lora-1fdg2": 1, }, }), - extprocutils.FakePodMetrics(2, backend.Metrics{ + extprocutils.FakePodMetrics(2, datastore.Metrics{ WaitingQueueSize: 6, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -224,8 +225,8 @@ func TestKubeInferenceModelRequest(t *testing.T) { req: extprocutils.GenerateRequest(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. - pods: []*backend.PodMetrics{ - extprocutils.FakePodMetrics(0, backend.Metrics{ + pods: []*datastore.PodMetrics{ + extprocutils.FakePodMetrics(0, datastore.Metrics{ WaitingQueueSize: 6, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -234,7 +235,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "sql-lora-1fdg3": 1, }, }), - extprocutils.FakePodMetrics(1, backend.Metrics{ + extprocutils.FakePodMetrics(1, datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.85, ActiveModels: map[string]int{ @@ -242,7 +243,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "sql-lora-1fdg3": 1, }, }), - extprocutils.FakePodMetrics(2, backend.Metrics{ + extprocutils.FakePodMetrics(2, datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, ActiveModels: map[string]int{ @@ -265,8 +266,8 @@ func TestKubeInferenceModelRequest(t *testing.T) { name: "noncritical, but one server has capacity, do not shed", req: extprocutils.GenerateRequest(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold - pods: []*backend.PodMetrics{ - extprocutils.FakePodMetrics(0, backend.Metrics{ + pods: []*datastore.PodMetrics{ + extprocutils.FakePodMetrics(0, datastore.Metrics{ WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -275,7 +276,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "sql-lora-1fdg3": 1, }, }), - extprocutils.FakePodMetrics(1, backend.Metrics{ + extprocutils.FakePodMetrics(1, datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.85, ActiveModels: map[string]int{ @@ -283,7 +284,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "sql-lora-1fdg3": 1, }, }), - extprocutils.FakePodMetrics(2, backend.Metrics{ + extprocutils.FakePodMetrics(2, datastore.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, ActiveModels: map[string]int{ @@ -364,8 +365,8 @@ func TestKubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(podMetrics []*backend.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - pms := make(map[types.NamespacedName]*backend.PodMetrics) +func setUpHermeticServer(podMetrics []*datastore.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + pms := make(map[types.NamespacedName]*datastore.PodMetrics) for _, pm := range podMetrics { pms[pm.NamespacedName] = pm } @@ -441,7 +442,7 @@ func BeforeSuit(t *testing.T) func() { serverRunner = runserver.NewDefaultExtProcServerRunner() // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" - serverRunner.Datastore = backend.NewDatastore() + serverRunner.Datastore = datastore.NewDatastore() serverRunner.SecureServing = false if err := serverRunner.SetupWithManager(mgr); err != nil { From cab2472a1238dcdd8993e5a6c5e3e00686555852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Wed, 19 Feb 2025 16:14:27 +0100 Subject: [PATCH 033/260] Move pkg/ext-proc -> cmd/ext-proc (#368) * Move pkg/ext-proc -> cmd/ext-proc * Rework Dockerfile - Cache dependencies - Upload only the files needed --- Dockerfile | 11 +++++++++-- {pkg => cmd}/ext-proc/health.go | 0 {pkg => cmd}/ext-proc/main.go | 2 +- go.mod | 2 +- {pkg/ext-proc/internal => internal}/runnable/grpc.go | 0 .../internal => internal}/runnable/leader_election.go | 0 pkg/ext-proc/server/runserver.go | 2 +- 7 files changed, 12 insertions(+), 5 deletions(-) rename {pkg => cmd}/ext-proc/health.go (100%) rename {pkg => cmd}/ext-proc/main.go (99%) rename {pkg/ext-proc/internal => internal}/runnable/grpc.go (100%) rename {pkg/ext-proc/internal => internal}/runnable/leader_election.go (100%) diff --git a/Dockerfile b/Dockerfile index e854e133..5d6f08a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,17 @@ ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 +# Dependencies WORKDIR /src -COPY . . -WORKDIR /src/pkg/ext-proc +COPY go.mod go.sum ./ RUN go mod download + +# Sources +COPY cmd ./cmd +COPY pkg ./pkg +COPY internal ./internal +COPY api ./api +WORKDIR /src/cmd/ext-proc RUN go build -o /ext-proc ## Multistage deploy diff --git a/pkg/ext-proc/health.go b/cmd/ext-proc/health.go similarity index 100% rename from pkg/ext-proc/health.go rename to cmd/ext-proc/health.go diff --git a/pkg/ext-proc/main.go b/cmd/ext-proc/main.go similarity index 99% rename from pkg/ext-proc/main.go rename to cmd/ext-proc/main.go index d43f2c57..fa4f5b4c 100644 --- a/pkg/ext-proc/main.go +++ b/cmd/ext-proc/main.go @@ -24,10 +24,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" diff --git a/go.mod b/go.mod index 25daf027..ca4a1633 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,6 @@ require ( k8s.io/client-go v0.32.2 k8s.io/code-generator v0.32.2 k8s.io/component-base v0.32.2 - k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.2 sigs.k8s.io/structured-merge-diff/v4 v4.5.0 @@ -137,6 +136,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiserver v0.32.2 // indirect k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect + k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect sigs.k8s.io/controller-tools v0.14.0 // indirect diff --git a/pkg/ext-proc/internal/runnable/grpc.go b/internal/runnable/grpc.go similarity index 100% rename from pkg/ext-proc/internal/runnable/grpc.go rename to internal/runnable/grpc.go diff --git a/pkg/ext-proc/internal/runnable/leader_election.go b/internal/runnable/leader_election.go similarity index 100% rename from pkg/ext-proc/internal/runnable/leader_election.go rename to internal/runnable/leader_election.go diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index 7b0209a6..eb6b2cf7 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -19,11 +19,11 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/controller" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" ) From 435a40d25b2e60fef5bac9188d03a712ecb77f53 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 19 Feb 2025 17:36:27 +0200 Subject: [PATCH 034/260] added license header to all .go files (#370) Signed-off-by: Nir Rozenbaum --- cmd/ext-proc/health.go | 16 ++++++++++++++++ cmd/ext-proc/main.go | 16 ++++++++++++++++ pkg/ext-proc/backend/fake.go | 16 ++++++++++++++++ pkg/ext-proc/backend/provider.go | 16 ++++++++++++++++ pkg/ext-proc/backend/provider_test.go | 16 ++++++++++++++++ pkg/ext-proc/backend/vllm/metrics.go | 16 ++++++++++++++++ pkg/ext-proc/backend/vllm/metrics_test.go | 16 ++++++++++++++++ .../controller/inferencemodel_reconciler.go | 16 ++++++++++++++++ .../controller/inferencemodel_reconciler_test.go | 16 ++++++++++++++++ .../controller/inferencepool_reconciler.go | 16 ++++++++++++++++ .../controller/inferencepool_reconciler_test.go | 16 ++++++++++++++++ pkg/ext-proc/controller/pod_reconciler.go | 16 ++++++++++++++++ pkg/ext-proc/controller/pod_reconciler_test.go | 16 ++++++++++++++++ pkg/ext-proc/datastore/datastore.go | 16 ++++++++++++++++ pkg/ext-proc/datastore/datastore_test.go | 16 ++++++++++++++++ pkg/ext-proc/datastore/types.go | 16 ++++++++++++++++ pkg/ext-proc/handlers/request.go | 16 ++++++++++++++++ pkg/ext-proc/handlers/response.go | 16 ++++++++++++++++ pkg/ext-proc/handlers/response_test.go | 16 ++++++++++++++++ pkg/ext-proc/handlers/server.go | 16 ++++++++++++++++ pkg/ext-proc/metrics/metrics.go | 16 ++++++++++++++++ pkg/ext-proc/metrics/metrics_test.go | 16 ++++++++++++++++ pkg/ext-proc/scheduling/filter.go | 16 ++++++++++++++++ pkg/ext-proc/scheduling/filter_test.go | 16 ++++++++++++++++ pkg/ext-proc/scheduling/scheduler.go | 16 ++++++++++++++++ pkg/ext-proc/scheduling/types.go | 16 ++++++++++++++++ pkg/ext-proc/server/runserver.go | 16 ++++++++++++++++ pkg/ext-proc/server/runserver_test.go | 16 ++++++++++++++++ pkg/ext-proc/test/benchmark/benchmark.go | 16 ++++++++++++++++ pkg/ext-proc/test/utils.go | 16 ++++++++++++++++ pkg/ext-proc/util/logging/fatal.go | 16 ++++++++++++++++ pkg/ext-proc/util/logging/logger.go | 16 ++++++++++++++++ pkg/ext-proc/util/logging/logging_const.go | 16 ++++++++++++++++ pkg/ext-proc/util/testing/wrappers.go | 16 ++++++++++++++++ test/integration/hermetic_test.go | 16 ++++++++++++++++ 35 files changed, 560 insertions(+) diff --git a/cmd/ext-proc/health.go b/cmd/ext-proc/health.go index 525440cb..26a58df8 100644 --- a/cmd/ext-proc/health.go +++ b/cmd/ext-proc/health.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 main import ( diff --git a/cmd/ext-proc/main.go b/cmd/ext-proc/main.go index fa4f5b4c..047a1fa7 100644 --- a/cmd/ext-proc/main.go +++ b/cmd/ext-proc/main.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 main import ( diff --git a/pkg/ext-proc/backend/fake.go b/pkg/ext-proc/backend/fake.go index 2ddf2932..2de34c16 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/ext-proc/backend/fake.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 backend import ( diff --git a/pkg/ext-proc/backend/provider.go b/pkg/ext-proc/backend/provider.go index 103659db..974319f7 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/ext-proc/backend/provider.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 backend import ( diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/ext-proc/backend/provider_test.go index 95936f7e..7736dd8d 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/ext-proc/backend/provider_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 backend import ( diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index 4785e484..2fd03172 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 vllm provides vllm specific pod metrics implementation. package vllm diff --git a/pkg/ext-proc/backend/vllm/metrics_test.go b/pkg/ext-proc/backend/vllm/metrics_test.go index 23121ad5..1c9d5448 100644 --- a/pkg/ext-proc/backend/vllm/metrics_test.go +++ b/pkg/ext-proc/backend/vllm/metrics_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 vllm import ( diff --git a/pkg/ext-proc/controller/inferencemodel_reconciler.go b/pkg/ext-proc/controller/inferencemodel_reconciler.go index a4622988..cca05fce 100644 --- a/pkg/ext-proc/controller/inferencemodel_reconciler.go +++ b/pkg/ext-proc/controller/inferencemodel_reconciler.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 controller import ( diff --git a/pkg/ext-proc/controller/inferencemodel_reconciler_test.go b/pkg/ext-proc/controller/inferencemodel_reconciler_test.go index c3ebb646..583f5f75 100644 --- a/pkg/ext-proc/controller/inferencemodel_reconciler_test.go +++ b/pkg/ext-proc/controller/inferencemodel_reconciler_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 controller import ( diff --git a/pkg/ext-proc/controller/inferencepool_reconciler.go b/pkg/ext-proc/controller/inferencepool_reconciler.go index 5c9e4969..b2cd01c0 100644 --- a/pkg/ext-proc/controller/inferencepool_reconciler.go +++ b/pkg/ext-proc/controller/inferencepool_reconciler.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 controller import ( diff --git a/pkg/ext-proc/controller/inferencepool_reconciler_test.go b/pkg/ext-proc/controller/inferencepool_reconciler_test.go index ec2fdfe1..925cb236 100644 --- a/pkg/ext-proc/controller/inferencepool_reconciler_test.go +++ b/pkg/ext-proc/controller/inferencepool_reconciler_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 controller import ( diff --git a/pkg/ext-proc/controller/pod_reconciler.go b/pkg/ext-proc/controller/pod_reconciler.go index 209d2ca7..871e1da5 100644 --- a/pkg/ext-proc/controller/pod_reconciler.go +++ b/pkg/ext-proc/controller/pod_reconciler.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 controller import ( diff --git a/pkg/ext-proc/controller/pod_reconciler_test.go b/pkg/ext-proc/controller/pod_reconciler_test.go index b146745a..2e62be28 100644 --- a/pkg/ext-proc/controller/pod_reconciler_test.go +++ b/pkg/ext-proc/controller/pod_reconciler_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 controller import ( diff --git a/pkg/ext-proc/datastore/datastore.go b/pkg/ext-proc/datastore/datastore.go index f85f9014..76b61e77 100644 --- a/pkg/ext-proc/datastore/datastore.go +++ b/pkg/ext-proc/datastore/datastore.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 datastore import ( diff --git a/pkg/ext-proc/datastore/datastore_test.go b/pkg/ext-proc/datastore/datastore_test.go index 6c5874df..f32d8d77 100644 --- a/pkg/ext-proc/datastore/datastore_test.go +++ b/pkg/ext-proc/datastore/datastore_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 datastore import ( diff --git a/pkg/ext-proc/datastore/types.go b/pkg/ext-proc/datastore/types.go index 221c6630..c21a3d2b 100644 --- a/pkg/ext-proc/datastore/types.go +++ b/pkg/ext-proc/datastore/types.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 datastore is a library to interact with backend model servers such as probing metrics. package datastore diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index b3ef08e0..7f6178d6 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 handlers import ( diff --git a/pkg/ext-proc/handlers/response.go b/pkg/ext-proc/handlers/response.go index 06da8106..afe7549b 100644 --- a/pkg/ext-proc/handlers/response.go +++ b/pkg/ext-proc/handlers/response.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 handlers import ( diff --git a/pkg/ext-proc/handlers/response_test.go b/pkg/ext-proc/handlers/response_test.go index 67875e05..dbb7e700 100644 --- a/pkg/ext-proc/handlers/response_test.go +++ b/pkg/ext-proc/handlers/response_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 handlers import ( diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index 05de0c42..a5274275 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 handlers import ( diff --git a/pkg/ext-proc/metrics/metrics.go b/pkg/ext-proc/metrics/metrics.go index e3226f47..a396f4ae 100644 --- a/pkg/ext-proc/metrics/metrics.go +++ b/pkg/ext-proc/metrics/metrics.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 metrics import ( diff --git a/pkg/ext-proc/metrics/metrics_test.go b/pkg/ext-proc/metrics/metrics_test.go index d24afdb1..cf638b93 100644 --- a/pkg/ext-proc/metrics/metrics_test.go +++ b/pkg/ext-proc/metrics/metrics_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 metrics import ( diff --git a/pkg/ext-proc/scheduling/filter.go b/pkg/ext-proc/scheduling/filter.go index 4d53e720..36691a73 100644 --- a/pkg/ext-proc/scheduling/filter.go +++ b/pkg/ext-proc/scheduling/filter.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling import ( diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/ext-proc/scheduling/filter_test.go index b2ae4b89..01909fea 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/ext-proc/scheduling/filter_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling import ( diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index 1e56fee3..49402fb3 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling implements request scheduling algorithms. package scheduling diff --git a/pkg/ext-proc/scheduling/types.go b/pkg/ext-proc/scheduling/types.go index cfb9d3b8..29e6648d 100644 --- a/pkg/ext-proc/scheduling/types.go +++ b/pkg/ext-proc/scheduling/types.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling // LLMRequest is a structured representation of the fields we parse out of the LLMRequest body. diff --git a/pkg/ext-proc/server/runserver.go b/pkg/ext-proc/server/runserver.go index eb6b2cf7..795b242d 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/ext-proc/server/runserver.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 server import ( diff --git a/pkg/ext-proc/server/runserver_test.go b/pkg/ext-proc/server/runserver_test.go index 32af2cd8..438dc096 100644 --- a/pkg/ext-proc/server/runserver_test.go +++ b/pkg/ext-proc/server/runserver_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 server_test import ( diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/ext-proc/test/benchmark/benchmark.go index 3820998d..dc06a27a 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/ext-proc/test/benchmark/benchmark.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 main import ( diff --git a/pkg/ext-proc/test/utils.go b/pkg/ext-proc/test/utils.go index a2d833e0..ef83c932 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/ext-proc/test/utils.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 test import ( diff --git a/pkg/ext-proc/util/logging/fatal.go b/pkg/ext-proc/util/logging/fatal.go index 1f85b450..d8a9a937 100644 --- a/pkg/ext-proc/util/logging/fatal.go +++ b/pkg/ext-proc/util/logging/fatal.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 logging import ( diff --git a/pkg/ext-proc/util/logging/logger.go b/pkg/ext-proc/util/logging/logger.go index 086a012f..5e6ed88d 100644 --- a/pkg/ext-proc/util/logging/logger.go +++ b/pkg/ext-proc/util/logging/logger.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 logging import ( diff --git a/pkg/ext-proc/util/logging/logging_const.go b/pkg/ext-proc/util/logging/logging_const.go index a6131d18..823ab28b 100644 --- a/pkg/ext-proc/util/logging/logging_const.go +++ b/pkg/ext-proc/util/logging/logging_const.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 logging const ( diff --git a/pkg/ext-proc/util/testing/wrappers.go b/pkg/ext-proc/util/testing/wrappers.go index f9005499..7c9a2939 100644 --- a/pkg/ext-proc/util/testing/wrappers.go +++ b/pkg/ext-proc/util/testing/wrappers.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 testing import ( diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 89fc02d7..18efe7bf 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 test contains e2e tests for the ext proc while faking the backend pods. package integration From 0f67df5bcf2df64cf54b87f73a15262d89925310 Mon Sep 17 00:00:00 2001 From: Kuromesi Date: Thu, 20 Feb 2025 00:00:27 +0800 Subject: [PATCH 035/260] fix inference extension not correctly scrape pod metrics (#366) Signed-off-by: Kuromesi --- pkg/ext-proc/backend/vllm/metrics.go | 2 +- pkg/ext-proc/controller/pod_reconciler_test.go | 10 +++++----- pkg/ext-proc/datastore/datastore.go | 5 ++++- pkg/ext-proc/datastore/types.go | 10 ++++++++++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/ext-proc/backend/vllm/metrics.go index 2fd03172..59a132c8 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/ext-proc/backend/vllm/metrics.go @@ -61,7 +61,7 @@ func (p *PodMetricsClientImpl) FetchMetrics( // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. - url := fmt.Sprintf("http://%s/metrics", existing.Address) + url := existing.BuildScrapeEndpoint() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { loggerDefault.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) diff --git a/pkg/ext-proc/controller/pod_reconciler_test.go b/pkg/ext-proc/controller/pod_reconciler_test.go index 2e62be28..c87ee54d 100644 --- a/pkg/ext-proc/controller/pod_reconciler_test.go +++ b/pkg/ext-proc/controller/pod_reconciler_test.go @@ -36,10 +36,10 @@ import ( ) var ( - basePod1 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1"}} - basePod2 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2"}} - basePod3 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3"}} - basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11"}} + basePod1 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1", ScrapePath: "/metrics", ScrapePort: 8000}} + basePod2 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2", ScrapePath: "/metrics", ScrapePort: 8000}} + basePod3 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3", ScrapePath: "/metrics", ScrapePort: 8000}} + basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11", ScrapePath: "/metrics", ScrapePort: 8000}} ) func TestUpdateDatastore_PodReconciler(t *testing.T) { @@ -278,7 +278,7 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { func populateMap(pods ...*datastore.PodMetrics) *sync.Map { newMap := &sync.Map{} for _, pod := range pods { - newMap.Store(pod.NamespacedName, &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: pod.NamespacedName, Address: pod.Address}}) + newMap.Store(pod.NamespacedName, &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: pod.NamespacedName, Address: pod.Address, ScrapePort: pod.ScrapePort, ScrapePath: pod.ScrapePath}}) } return newMap } diff --git a/pkg/ext-proc/datastore/datastore.go b/pkg/ext-proc/datastore/datastore.go index 76b61e77..60236496 100644 --- a/pkg/ext-proc/datastore/datastore.go +++ b/pkg/ext-proc/datastore/datastore.go @@ -182,13 +182,16 @@ func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { } func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool { + pool, _ := ds.PoolGet() new := &PodMetrics{ Pod: Pod{ NamespacedName: types.NamespacedName{ Name: pod.Name, Namespace: pod.Namespace, }, - Address: pod.Status.PodIP, + Address: pod.Status.PodIP, + ScrapePath: "/metrics", + ScrapePort: pool.Spec.TargetPortNumber, }, Metrics: Metrics{ ActiveModels: make(map[string]int), diff --git a/pkg/ext-proc/datastore/types.go b/pkg/ext-proc/datastore/types.go index c21a3d2b..237e98ca 100644 --- a/pkg/ext-proc/datastore/types.go +++ b/pkg/ext-proc/datastore/types.go @@ -26,6 +26,10 @@ import ( type Pod struct { NamespacedName types.NamespacedName Address string + + // metrics scrape options + ScrapePort int32 + ScrapePath string } type Metrics struct { @@ -57,6 +61,8 @@ func (pm *PodMetrics) Clone() *PodMetrics { Pod: Pod{ NamespacedName: pm.NamespacedName, Address: pm.Address, + ScrapePort: pm.ScrapePort, + ScrapePath: pm.ScrapePath, }, Metrics: Metrics{ ActiveModels: cm, @@ -68,3 +74,7 @@ func (pm *PodMetrics) Clone() *PodMetrics { } return clone } + +func (pm *PodMetrics) BuildScrapeEndpoint() string { + return fmt.Sprintf("http://%s:%d%s", pm.Address, pm.ScrapePort, pm.ScrapePath) +} From 9f346734d7e3c5d3b7eec835f606394006471772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Wed, 19 Feb 2025 17:18:27 +0100 Subject: [PATCH 036/260] Move pkg/manifests -> config/manifests (#371) --- {pkg => config}/manifests/ext_proc.yaml | 0 .../manifests/gateway/enable_patch_policy.yaml | 0 .../manifests/gateway/extension_policy.yaml | 0 {pkg => config}/manifests/gateway/gateway.yaml | 0 .../manifests/gateway/patch_policy.yaml | 0 .../manifests/gateway/traffic_policy.yaml | 0 {pkg => config}/manifests/inferencemodel.yaml | 0 {pkg => config}/manifests/vllm/deployment.yaml | 0 hack/release-quickstart.sh | 8 ++++---- site-src/guides/index.md | 16 ++++++++-------- test/e2e/e2e_suite_test.go | 4 ++-- 11 files changed, 14 insertions(+), 14 deletions(-) rename {pkg => config}/manifests/ext_proc.yaml (100%) rename {pkg => config}/manifests/gateway/enable_patch_policy.yaml (100%) rename {pkg => config}/manifests/gateway/extension_policy.yaml (100%) rename {pkg => config}/manifests/gateway/gateway.yaml (100%) rename {pkg => config}/manifests/gateway/patch_policy.yaml (100%) rename {pkg => config}/manifests/gateway/traffic_policy.yaml (100%) rename {pkg => config}/manifests/inferencemodel.yaml (100%) rename {pkg => config}/manifests/vllm/deployment.yaml (100%) diff --git a/pkg/manifests/ext_proc.yaml b/config/manifests/ext_proc.yaml similarity index 100% rename from pkg/manifests/ext_proc.yaml rename to config/manifests/ext_proc.yaml diff --git a/pkg/manifests/gateway/enable_patch_policy.yaml b/config/manifests/gateway/enable_patch_policy.yaml similarity index 100% rename from pkg/manifests/gateway/enable_patch_policy.yaml rename to config/manifests/gateway/enable_patch_policy.yaml diff --git a/pkg/manifests/gateway/extension_policy.yaml b/config/manifests/gateway/extension_policy.yaml similarity index 100% rename from pkg/manifests/gateway/extension_policy.yaml rename to config/manifests/gateway/extension_policy.yaml diff --git a/pkg/manifests/gateway/gateway.yaml b/config/manifests/gateway/gateway.yaml similarity index 100% rename from pkg/manifests/gateway/gateway.yaml rename to config/manifests/gateway/gateway.yaml diff --git a/pkg/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml similarity index 100% rename from pkg/manifests/gateway/patch_policy.yaml rename to config/manifests/gateway/patch_policy.yaml diff --git a/pkg/manifests/gateway/traffic_policy.yaml b/config/manifests/gateway/traffic_policy.yaml similarity index 100% rename from pkg/manifests/gateway/traffic_policy.yaml rename to config/manifests/gateway/traffic_policy.yaml diff --git a/pkg/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml similarity index 100% rename from pkg/manifests/inferencemodel.yaml rename to config/manifests/inferencemodel.yaml diff --git a/pkg/manifests/vllm/deployment.yaml b/config/manifests/vllm/deployment.yaml similarity index 100% rename from pkg/manifests/vllm/deployment.yaml rename to config/manifests/vllm/deployment.yaml diff --git a/hack/release-quickstart.sh b/hack/release-quickstart.sh index f4701508..a21047c3 100755 --- a/hack/release-quickstart.sh +++ b/hack/release-quickstart.sh @@ -36,9 +36,9 @@ sed -i.bak -E "s|(releases/download/)v[0-9]+\.[0-9]+\.0-rc\.?[0-9]+|\1${RELEASE_ sed -i.bak "s|kubectl apply -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd|kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/${RELEASE_TAG}/manifests.yaml|g" "$README" # ----------------------------------------------------------------------------- -# Update pkg/manifests/ext_proc.yaml +# Update config/manifests/ext_proc.yaml # ----------------------------------------------------------------------------- -EXT_PROC="pkg/manifests/ext_proc.yaml" +EXT_PROC="config/manifests/ext_proc.yaml" echo "Updating ${EXT_PROC} ..." # Update the EPP container tag. @@ -51,9 +51,9 @@ sed -i.bak '/us-central1-docker.pkg.dev\/k8s-staging-images\/gateway-api-inferen sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EXT_PROC" # ----------------------------------------------------------------------------- -# Update pkg/manifests/vllm/deployment.yaml +# Update config/manifests/vllm/deployment.yaml # ----------------------------------------------------------------------------- -VLLM_DEPLOY="pkg/manifests/vllm/deployment.yaml" +VLLM_DEPLOY="config/manifests/vllm/deployment.yaml" echo "Updating ${VLLM_DEPLOY} ..." # Update the vLLM image version diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 34fff20c..4478128f 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -17,7 +17,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/vllm/deployment.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/deployment.yaml ``` ### Install the Inference Extension CRDs @@ -31,14 +31,14 @@ This quickstart guide is intended for engineers familiar with k8s and model serv Deploy the sample InferenceModel which is configured to load balance traffic between the `tweet-summary-0` and `tweet-summary-1` [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/inferencemodel.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml ``` ### Update Envoy Gateway Config to enable Patch Policy** Our custom LLM Gateway ext-proc is patched into the existing envoy gateway via `EnvoyPatchPolicy`. To enable this feature, we must extend the Envoy Gateway config map. To do this, simply run: ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/enable_patch_policy.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/enable_patch_policy.yaml kubectl rollout restart deployment envoy-gateway -n envoy-gateway-system ``` Additionally, if you would like to enable the admin interface, you can uncomment the admin lines and run this again. @@ -46,7 +46,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Deploy Gateway ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/gateway.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gateway.yaml ``` > **_NOTE:_** This file couples together the gateway infra and the HTTPRoute infra for a convenient, quick startup. Creating additional/different InferencePools on the same gateway will require an additional set of: `Backend`, `HTTPRoute`, the resources included in the `./manifests/gateway/ext-proc.yaml` file, and an additional `./manifests/gateway/patch_policy.yaml` file. ***Should you choose to experiment, familiarity with xDS and Envoy are very useful.*** @@ -59,13 +59,13 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Deploy the Inference Extension and InferencePool ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/ext_proc.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/ext_proc.yaml ``` ### Deploy Envoy Gateway Custom Policies ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/extension_policy.yaml - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/patch_policy.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/extension_policy.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/patch_policy.yaml ``` > **_NOTE:_** This is also per InferencePool, and will need to be configured to support the new pool should you wish to experiment further. @@ -74,7 +74,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv For high-traffic benchmarking you can apply this manifest to avoid any defaults that can cause timeouts/errors. ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/pkg/manifests/gateway/traffic_policy.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/traffic_policy.yaml ``` ### Try it out diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 4a0dd2a8..c4342775 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -69,7 +69,7 @@ const ( // clientManifest is the manifest for the client test resources. clientManifest = "../testdata/client.yaml" // modelServerManifest is the manifest for the model server test resources. - modelServerManifest = "../../pkg/manifests/vllm/deployment.yaml" + modelServerManifest = "../../config/manifests/vllm/deployment.yaml" // modelServerSecretManifest is the manifest for the model server secret resource. modelServerSecretManifest = "../testdata/model-secret.yaml" // inferPoolManifest is the manifest for the inference pool CRD. @@ -77,7 +77,7 @@ const ( // inferModelManifest is the manifest for the inference model CRD. inferModelManifest = "../../config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml" // inferExtManifest is the manifest for the inference extension test resources. - inferExtManifest = "../../pkg/manifests/ext_proc.yaml" + inferExtManifest = "../../config/manifests/ext_proc.yaml" // envoyManifest is the manifest for the envoy proxy test resources. envoyManifest = "../testdata/envoy.yaml" ) From 6130ee0cd2a4f551002fc6e218615d7bf342cc44 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Wed, 19 Feb 2025 13:58:27 -0500 Subject: [PATCH 037/260] [Metrics] Add request error metrics (#269) This change defines some general errors, the list might grow in the future if more finer error types are needed. --- pkg/ext-proc/handlers/request.go | 14 ++-- pkg/ext-proc/handlers/response.go | 40 +++++++++- pkg/ext-proc/handlers/server.go | 58 ++++++++++++-- pkg/ext-proc/metrics/README.md | 1 + pkg/ext-proc/metrics/metrics.go | 18 +++++ pkg/ext-proc/metrics/metrics_test.go | 77 +++++++++++++++++-- .../testdata/request_error_total_metric | 5 ++ pkg/ext-proc/scheduling/scheduler.go | 7 +- pkg/ext-proc/util/error/error.go | 34 ++++++++ 9 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 pkg/ext-proc/metrics/testdata/request_error_total_metric create mode 100644 pkg/ext-proc/util/error/error.go diff --git a/pkg/ext-proc/handlers/request.go b/pkg/ext-proc/handlers/request.go index 7f6178d6..34db206d 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/ext-proc/handlers/request.go @@ -19,7 +19,6 @@ package handlers import ( "context" "encoding/json" - "errors" "fmt" "strconv" @@ -29,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -49,14 +49,14 @@ func (s *Server) HandleRequestBody( var rb map[string]interface{} if err := json.Unmarshal(v.RequestBody.Body, &rb); err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") - return nil, fmt.Errorf("error unmarshaling request body: %v", err) + return nil, errutil.Error{Code: errutil.BadRequest, Msg: fmt.Sprintf("error unmarshaling request body: %v", err)} } loggerVerbose.Info("Request body unmarshalled", "body", rb) // Resolve target models. model, ok := rb["model"].(string) if !ok { - return nil, errors.New("model not found in request") + return nil, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} } loggerVerbose.Info("Model requested", "model", model) modelName := model @@ -66,12 +66,12 @@ func (s *Server) HandleRequestBody( // are able to be requested by using their distinct name. modelObj, exist := s.datastore.ModelGet(model) if !exist { - return nil, fmt.Errorf("error finding a model object in InferenceModel for input %v", model) + return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} } if len(modelObj.Spec.TargetModels) > 0 { modelName = datastore.RandomWeightedDraw(logger, modelObj, 0) if modelName == "" { - return nil, fmt.Errorf("error getting target model name for model %v", modelObj.Name) + return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } } llmReq := &scheduling.LLMRequest{ @@ -89,14 +89,14 @@ func (s *Server) HandleRequestBody( requestBody, err = json.Marshal(rb) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") - return nil, fmt.Errorf("error marshaling request body: %v", err) + return nil, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} } loggerVerbose.Info("Updated request body marshalled", "body", string(requestBody)) } targetPod, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { - return nil, fmt.Errorf("failed to find target pod: %w", err) + return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} } logger.V(logutil.DEFAULT).Info("Request handled", diff --git a/pkg/ext-proc/handlers/response.go b/pkg/ext-proc/handlers/response.go index afe7549b..ed3082c5 100644 --- a/pkg/ext-proc/handlers/response.go +++ b/pkg/ext-proc/handlers/response.go @@ -24,6 +24,7 @@ import ( configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "sigs.k8s.io/controller-runtime/pkg/log" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -38,6 +39,43 @@ func (s *Server) HandleResponseHeaders( h := req.Request.(*extProcPb.ProcessingRequest_ResponseHeaders) loggerVerbose.Info("Headers before", "headers", h) + // Example header + // { + // "ResponseHeaders": { + // "headers": [ + // { + // "key": ":status", + // "raw_value": "200" + // }, + // { + // "key": "date", + // "raw_value": "Thu, 30 Jan 2025 18:50:48 GMT" + // }, + // { + // "key": "server", + // "raw_value": "uvicorn" + // }, + // { + // "key": "content-type", + // "raw_value": "text/event-stream; charset=utf-8" + // }, + // { + // "key": "transfer-encoding", + // "raw_value": "chunked" + // } + // ] + // } + // } + for _, header := range h.ResponseHeaders.Headers.GetHeaders() { + if header.Key == "status" { + code := header.RawValue[0] + if string(code) != "200" { + reqCtx.ResponseStatusCode = errutil.ModelServerError + } + break + } + } + resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseHeaders{ ResponseHeaders: &extProcPb.HeadersResponse{ @@ -99,7 +137,7 @@ func (s *Server) HandleResponseBody( res := Response{} if err := json.Unmarshal(body.ResponseBody.Body, &res); err != nil { - return nil, fmt.Errorf("unmarshaling response body: %v", err) + return nil, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("unmarshaling response body: %v", err)} } reqCtx.Response = res reqCtx.ResponseSize = len(body.ResponseBody.Body) diff --git a/pkg/ext-proc/handlers/server.go b/pkg/ext-proc/handlers/server.go index a5274275..506eaa97 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/ext-proc/handlers/server.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -65,6 +66,18 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { // See https://github.com/envoyproxy/envoy/issues/17540. reqCtx := &RequestContext{} + // Create variable for error handling as each request should only report once for + // error metric. This doesn't cover the error "Cannot receive stream request" because + // such error might happen even the response is processed. + var err error + defer func(error) { + if reqCtx.ResponseStatusCode != "" { + metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseStatusCode) + } else if err != nil { + metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, errutil.CanonicalCode(err)) + } + }(err) + for { select { case <-ctx.Done(): @@ -72,11 +85,11 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { default: } - req, err := srv.Recv() - if err == io.EOF || errors.Is(err, context.Canceled) { + req, recvErr := srv.Recv() + if recvErr == io.EOF || errors.Is(recvErr, context.Canceled) { return nil } - if err != nil { + if recvErr != nil { // This error occurs very frequently, though it doesn't seem to have any impact. // TODO Figure out if we can remove this noise. loggerVerbose.Error(err, "Cannot receive stream request") @@ -113,12 +126,13 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { logger.V(logutil.DEFAULT).Error(nil, "Unknown Request type", "request", v) return status.Error(codes.Unknown, "unknown request type") } + if err != nil { logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) - switch status.Code(err) { + switch errutil.CanonicalCode(err) { // This code can be returned by scheduler when there is no capacity for sheddable // requests. - case codes.ResourceExhausted: + case errutil.InferencePoolResourceExhausted: resp = &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ImmediateResponse{ ImmediateResponse: &extProcPb.ImmediateResponse{ @@ -128,6 +142,38 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { }, }, } + // This code can be returned by when EPP processes the request and run into server-side errors. + case errutil.Internal: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_InternalServerError, + }, + }, + }, + } + // This code can be returned when users provide invalid json request. + case errutil.BadRequest: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_BadRequest, + }, + }, + }, + } + case errutil.BadConfiguration: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_NotFound, + }, + }, + }, + } default: return status.Errorf(status.Code(err), "failed to handle request: %v", err) } @@ -139,6 +185,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } + } // RequestContext stores context information during the life time of an HTTP request. @@ -153,4 +200,5 @@ type RequestContext struct { Response Response ResponseSize int ResponseComplete bool + ResponseStatusCode string } diff --git a/pkg/ext-proc/metrics/README.md b/pkg/ext-proc/metrics/README.md index 8adfd94e..1f68a0bd 100644 --- a/pkg/ext-proc/metrics/README.md +++ b/pkg/ext-proc/metrics/README.md @@ -41,6 +41,7 @@ spec: | Metric name | Metric Type | Description | Labels | Status | | ------------|--------------| ----------- | ------ | ------ | | inference_model_request_total | Counter | The counter of requests broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_request_error_total | Counter | The counter of requests errors broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_request_duration_seconds | Distribution | Distribution of response latency. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_request_sizes | Distribution | Distribution of request size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_response_sizes | Distribution | Distribution of response size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | diff --git a/pkg/ext-proc/metrics/metrics.go b/pkg/ext-proc/metrics/metrics.go index a396f4ae..cc21d531 100644 --- a/pkg/ext-proc/metrics/metrics.go +++ b/pkg/ext-proc/metrics/metrics.go @@ -44,6 +44,16 @@ var ( []string{"model_name", "target_model_name"}, ) + requestErrCounter = compbasemetrics.NewCounterVec( + &compbasemetrics.CounterOpts{ + Subsystem: InferenceModelComponent, + Name: "request_error_total", + Help: "Counter of inference model requests errors broken out for each model and target model.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"model_name", "target_model_name", "error_code"}, + ) + requestLatencies = compbasemetrics.NewHistogramVec( &compbasemetrics.HistogramOpts{ Subsystem: InferenceModelComponent, @@ -139,6 +149,7 @@ var registerMetrics sync.Once func Register() { registerMetrics.Do(func() { legacyregistry.MustRegister(requestCounter) + legacyregistry.MustRegister(requestErrCounter) legacyregistry.MustRegister(requestLatencies) legacyregistry.MustRegister(requestSizes) legacyregistry.MustRegister(responseSizes) @@ -155,6 +166,13 @@ func RecordRequestCounter(modelName, targetModelName string) { requestCounter.WithLabelValues(modelName, targetModelName).Inc() } +// RecordRequestErrCounter records the number of error requests. +func RecordRequestErrCounter(modelName, targetModelName string, code string) { + if code != "" { + requestErrCounter.WithLabelValues(modelName, targetModelName, code).Inc() + } +} + // RecordRequestSizes records the request sizes. func RecordRequestSizes(modelName, targetModelName string, reqSize int) { requestSizes.WithLabelValues(modelName, targetModelName).Observe(float64(reqSize)) diff --git a/pkg/ext-proc/metrics/metrics_test.go b/pkg/ext-proc/metrics/metrics_test.go index cf638b93..2e891066 100644 --- a/pkg/ext-proc/metrics/metrics_test.go +++ b/pkg/ext-proc/metrics/metrics_test.go @@ -24,18 +24,20 @@ import ( "k8s.io/component-base/metrics/legacyregistry" "k8s.io/component-base/metrics/testutil" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) const ( - RequestTotalMetric = InferenceModelComponent + "_request_total" - RequestLatenciesMetric = InferenceModelComponent + "_request_duration_seconds" - RequestSizesMetric = InferenceModelComponent + "_request_sizes" - ResponseSizesMetric = InferenceModelComponent + "_response_sizes" - InputTokensMetric = InferenceModelComponent + "_input_tokens" - OutputTokensMetric = InferenceModelComponent + "_output_tokens" - KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" - QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" + RequestTotalMetric = InferenceModelComponent + "_request_total" + RequestErrorTotalMetric = InferenceModelComponent + "_request_error_total" + RequestLatenciesMetric = InferenceModelComponent + "_request_duration_seconds" + RequestSizesMetric = InferenceModelComponent + "_request_sizes" + ResponseSizesMetric = InferenceModelComponent + "_response_sizes" + InputTokensMetric = InferenceModelComponent + "_input_tokens" + OutputTokensMetric = InferenceModelComponent + "_output_tokens" + KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" + QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" ) func TestRecordRequestCounterandSizes(t *testing.T) { @@ -107,6 +109,65 @@ func TestRecordRequestCounterandSizes(t *testing.T) { } } +func TestRecordRequestErrorCounter(t *testing.T) { + type requests struct { + modelName string + targetModelName string + error string + } + scenarios := []struct { + name string + reqs []requests + invalid bool + }{{ + name: "multiple requests", + reqs: []requests{ + { + modelName: "m10", + targetModelName: "t10", + error: errutil.Internal, + }, + { + modelName: "m10", + targetModelName: "t10", + error: errutil.Internal, + }, + { + modelName: "m10", + targetModelName: "t11", + error: errutil.ModelServerError, + }, + { + modelName: "m20", + targetModelName: "t20", + error: errutil.InferencePoolResourceExhausted, + }, + }, + }, + } + Register() + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + for _, req := range scenario.reqs { + RecordRequestErrCounter(req.modelName, req.targetModelName, req.error) + } + + wantRequestErrorCounter, err := os.Open("testdata/request_error_total_metric") + defer func() { + if err := wantRequestErrorCounter.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRequestErrorCounter, RequestErrorTotalMetric); err != nil { + t.Error(err) + } + }) + } +} + func TestRecordRequestLatencies(t *testing.T) { ctx := logutil.NewTestLoggerIntoContext(context.Background()) timeBaseline := time.Now() diff --git a/pkg/ext-proc/metrics/testdata/request_error_total_metric b/pkg/ext-proc/metrics/testdata/request_error_total_metric new file mode 100644 index 00000000..31036eb6 --- /dev/null +++ b/pkg/ext-proc/metrics/testdata/request_error_total_metric @@ -0,0 +1,5 @@ +# HELP inference_model_request_error_total [ALPHA] Counter of inference model requests errors broken out for each model and target model. +# TYPE inference_model_request_error_total counter +inference_model_request_error_total{error_code="Internal", model_name="m10",target_model_name="t10"} 2 +inference_model_request_error_total{error_code="ModelServerError", model_name="m10",target_model_name="t11"} 1 +inference_model_request_error_total{error_code="InferencePoolResourceExhausted", model_name="m20",target_model_name="t20"} 1 diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/ext-proc/scheduling/scheduler.go index 49402fb3..b5f2f4f2 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/ext-proc/scheduling/scheduler.go @@ -23,10 +23,9 @@ import ( "math/rand" "github.com/go-logr/logr" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" ) @@ -102,8 +101,8 @@ var ( name: "drop request", filter: func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { logger.V(logutil.DEFAULT).Info("Request dropped", "request", req) - return []*datastore.PodMetrics{}, status.Errorf( - codes.ResourceExhausted, "dropping request due to limited backend resources") + return []*datastore.PodMetrics{}, errutil.Error{ + Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources"} }, }, } diff --git a/pkg/ext-proc/util/error/error.go b/pkg/ext-proc/util/error/error.go new file mode 100644 index 00000000..2f9c992c --- /dev/null +++ b/pkg/ext-proc/util/error/error.go @@ -0,0 +1,34 @@ +package error + +import ( + "fmt" +) + +// Error is an error struct for errors returned by the epp server. +type Error struct { + Code string + Msg string +} + +const ( + Unknown = "Unknown" + BadRequest = "BadRequest" + Internal = "Internal" + ModelServerError = "ModelServerError" + BadConfiguration = "BadConfiguration" + InferencePoolResourceExhausted = "InferencePoolResourceExhausted" +) + +// Error returns a string version of the error. +func (e Error) Error() string { + return fmt.Sprintf("inference gateway: %s - %s", e.Code, e.Msg) +} + +// CanonicalCode returns the error's ErrorCode. +func CanonicalCode(err error) string { + e, ok := err.(Error) + if ok { + return e.Code + } + return Unknown +} From 2577f63f6a1c94292393a9750975320f2462485d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Kupka?= Date: Wed, 19 Feb 2025 22:34:27 +0100 Subject: [PATCH 038/260] Rename pkg/ext-proc to pkg/epp (#372) --- Dockerfile | 10 ++-- cmd/{ext-proc => epp}/health.go | 4 +- cmd/{ext-proc => epp}/main.go | 12 ++--- docs/dev.md | 2 +- .../003-endpoint-picker-protocol/README.md | 2 +- pkg/{ext-proc => epp}/backend/fake.go | 4 +- pkg/{ext-proc => epp}/backend/provider.go | 6 +-- .../backend/provider_test.go | 2 +- pkg/{ext-proc => epp}/backend/vllm/metrics.go | 4 +- .../backend/vllm/metrics_test.go | 4 +- .../controller/inferencemodel_reconciler.go | 4 +- .../inferencemodel_reconciler_test.go | 4 +- .../controller/inferencepool_reconciler.go | 4 +- .../inferencepool_reconciler_test.go | 4 +- .../controller/pod_reconciler.go | 4 +- .../controller/pod_reconciler_test.go | 2 +- pkg/{ext-proc => epp}/datastore/datastore.go | 2 +- .../datastore/datastore_test.go | 2 +- pkg/{ext-proc => epp}/datastore/types.go | 0 pkg/{ext-proc => epp}/handlers/request.go | 8 +-- pkg/{ext-proc => epp}/handlers/response.go | 4 +- .../handlers/response_test.go | 2 +- pkg/{ext-proc => epp}/handlers/server.go | 11 ++-- pkg/{ext-proc => epp}/metrics/README.md | 0 pkg/{ext-proc => epp}/metrics/metrics.go | 2 +- pkg/{ext-proc => epp}/metrics/metrics_test.go | 51 ++++++++++--------- .../metrics/testdata/input_tokens_metric | 0 .../metrics/testdata/kv_cache_avg_metrics | 0 .../metrics/testdata/output_tokens_metric | 0 .../metrics/testdata/queue_avg_size_metrics | 0 .../testdata/request_duration_seconds_metric | 0 .../testdata/request_error_total_metric | 0 .../metrics/testdata/request_sizes_metric | 0 .../metrics/testdata/request_total_metric | 0 .../metrics/testdata/response_sizes_metric | 0 pkg/{ext-proc => epp}/scheduling/filter.go | 4 +- .../scheduling/filter_test.go | 4 +- pkg/{ext-proc => epp}/scheduling/scheduler.go | 9 ++-- pkg/{ext-proc => epp}/scheduling/types.go | 0 pkg/{ext-proc => epp}/server/runserver.go | 10 ++-- .../server/runserver_test.go | 4 +- .../test/benchmark/benchmark.go | 8 +-- pkg/{ext-proc => epp}/test/utils.go | 12 ++--- pkg/{ext-proc => epp}/util/error/error.go | 0 pkg/{ext-proc => epp}/util/logging/fatal.go | 0 pkg/{ext-proc => epp}/util/logging/logger.go | 0 .../util/logging/logging_const.go | 0 .../util/testing/wrappers.go | 0 test/integration/hermetic_test.go | 12 ++--- tools/dashboards/README.md | 3 +- tools/dashboards/inference_gateway.json | 2 +- 51 files changed, 112 insertions(+), 110 deletions(-) rename cmd/{ext-proc => epp}/health.go (91%) rename cmd/{ext-proc => epp}/main.go (95%) rename pkg/{ext-proc => epp}/backend/fake.go (90%) rename pkg/{ext-proc => epp}/backend/provider.go (95%) rename pkg/{ext-proc => epp}/backend/provider_test.go (98%) rename pkg/{ext-proc => epp}/backend/vllm/metrics.go (97%) rename pkg/{ext-proc => epp}/backend/vllm/metrics_test.go (97%) rename pkg/{ext-proc => epp}/controller/inferencemodel_reconciler.go (95%) rename pkg/{ext-proc => epp}/controller/inferencemodel_reconciler_test.go (98%) rename pkg/{ext-proc => epp}/controller/inferencepool_reconciler.go (96%) rename pkg/{ext-proc => epp}/controller/inferencepool_reconciler_test.go (97%) rename pkg/{ext-proc => epp}/controller/pod_reconciler.go (95%) rename pkg/{ext-proc => epp}/controller/pod_reconciler_test.go (99%) rename pkg/{ext-proc => epp}/datastore/datastore.go (98%) rename pkg/{ext-proc => epp}/datastore/datastore_test.go (97%) rename pkg/{ext-proc => epp}/datastore/types.go (100%) rename pkg/{ext-proc => epp}/handlers/request.go (95%) rename pkg/{ext-proc => epp}/handlers/response.go (97%) rename pkg/{ext-proc => epp}/handlers/response_test.go (97%) rename pkg/{ext-proc => epp}/handlers/server.go (95%) rename pkg/{ext-proc => epp}/metrics/README.md (100%) rename pkg/{ext-proc => epp}/metrics/metrics.go (98%) rename pkg/{ext-proc => epp}/metrics/metrics_test.go (93%) rename pkg/{ext-proc => epp}/metrics/testdata/input_tokens_metric (100%) rename pkg/{ext-proc => epp}/metrics/testdata/kv_cache_avg_metrics (100%) rename pkg/{ext-proc => epp}/metrics/testdata/output_tokens_metric (100%) rename pkg/{ext-proc => epp}/metrics/testdata/queue_avg_size_metrics (100%) rename pkg/{ext-proc => epp}/metrics/testdata/request_duration_seconds_metric (100%) rename pkg/{ext-proc => epp}/metrics/testdata/request_error_total_metric (100%) rename pkg/{ext-proc => epp}/metrics/testdata/request_sizes_metric (100%) rename pkg/{ext-proc => epp}/metrics/testdata/request_total_metric (100%) rename pkg/{ext-proc => epp}/metrics/testdata/response_sizes_metric (100%) rename pkg/{ext-proc => epp}/scheduling/filter.go (98%) rename pkg/{ext-proc => epp}/scheduling/filter_test.go (98%) rename pkg/{ext-proc => epp}/scheduling/scheduler.go (94%) rename pkg/{ext-proc => epp}/scheduling/types.go (100%) rename pkg/{ext-proc => epp}/server/runserver.go (95%) rename pkg/{ext-proc => epp}/server/runserver_test.go (87%) rename pkg/{ext-proc => epp}/test/benchmark/benchmark.go (93%) rename pkg/{ext-proc => epp}/test/utils.go (88%) rename pkg/{ext-proc => epp}/util/error/error.go (100%) rename pkg/{ext-proc => epp}/util/logging/fatal.go (100%) rename pkg/{ext-proc => epp}/util/logging/logger.go (100%) rename pkg/{ext-proc => epp}/util/logging/logging_const.go (100%) rename pkg/{ext-proc => epp}/util/testing/wrappers.go (100%) diff --git a/Dockerfile b/Dockerfile index 5d6f08a5..4adc82e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG BUILDER_IMAGE=golang:1.23-alpine ARG BASE_IMAGE=gcr.io/distroless/base-debian10 ## Multistage build -FROM ${BUILDER_IMAGE} as builder +FROM ${BUILDER_IMAGE} AS builder ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 @@ -19,13 +19,13 @@ COPY cmd ./cmd COPY pkg ./pkg COPY internal ./internal COPY api ./api -WORKDIR /src/cmd/ext-proc -RUN go build -o /ext-proc +WORKDIR /src/cmd/epp +RUN go build -o /epp ## Multistage deploy FROM ${BASE_IMAGE} WORKDIR / -COPY --from=builder /ext-proc /ext-proc +COPY --from=builder /epp /epp -ENTRYPOINT ["/ext-proc"] \ No newline at end of file +ENTRYPOINT ["/epp"] diff --git a/cmd/ext-proc/health.go b/cmd/epp/health.go similarity index 91% rename from cmd/ext-proc/health.go rename to cmd/epp/health.go index 26a58df8..335c0849 100644 --- a/cmd/ext-proc/health.go +++ b/cmd/epp/health.go @@ -23,8 +23,8 @@ import ( "google.golang.org/grpc/codes" healthPb "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/status" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type healthServer struct { diff --git a/cmd/ext-proc/main.go b/cmd/epp/main.go similarity index 95% rename from cmd/ext-proc/main.go rename to cmd/epp/main.go index 047a1fa7..a189984b 100644 --- a/cmd/ext-proc/main.go +++ b/cmd/epp/main.go @@ -41,12 +41,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend/vllm" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/vllm" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( diff --git a/docs/dev.md b/docs/dev.md index 2af39668..d223ed6a 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -37,7 +37,7 @@ const( ) ``` -The guidelines are written in the context of a k8s controller. Our [ext-proc](../pkg/ext-proc/) does more things such as handling requests and scraping metrics, therefore we adapt the guidelines as follows: +The guidelines are written in the context of a k8s controller. Our [epp](../pkg/epp/) does more things such as handling requests and scraping metrics, therefore we adapt the guidelines as follows: 1. The server startup process and configuration. diff --git a/docs/proposals/003-endpoint-picker-protocol/README.md b/docs/proposals/003-endpoint-picker-protocol/README.md index 8e96a630..6876135d 100644 --- a/docs/proposals/003-endpoint-picker-protocol/README.md +++ b/docs/proposals/003-endpoint-picker-protocol/README.md @@ -2,7 +2,7 @@ The Endpoint Picker, or EPP, is a core component of the inference extension. Ultimately it's responsible for picking an endpoint from the `InferencePool`. A reference implementation can be -found [here](../../../pkg/ext-proc/). +found [here](../../../pkg/epp/). ## Proxy Protocol diff --git a/pkg/ext-proc/backend/fake.go b/pkg/epp/backend/fake.go similarity index 90% rename from pkg/ext-proc/backend/fake.go rename to pkg/epp/backend/fake.go index 2de34c16..e81b3817 100644 --- a/pkg/ext-proc/backend/fake.go +++ b/pkg/epp/backend/fake.go @@ -22,8 +22,8 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type FakePodMetricsClient struct { diff --git a/pkg/ext-proc/backend/provider.go b/pkg/epp/backend/provider.go similarity index 95% rename from pkg/ext-proc/backend/provider.go rename to pkg/epp/backend/provider.go index 974319f7..a12f84d5 100644 --- a/pkg/ext-proc/backend/provider.go +++ b/pkg/epp/backend/provider.go @@ -25,9 +25,9 @@ import ( "github.com/go-logr/logr" "go.uber.org/multierr" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( diff --git a/pkg/ext-proc/backend/provider_test.go b/pkg/epp/backend/provider_test.go similarity index 98% rename from pkg/ext-proc/backend/provider_test.go rename to pkg/epp/backend/provider_test.go index 7736dd8d..1e11afe2 100644 --- a/pkg/ext-proc/backend/provider_test.go +++ b/pkg/epp/backend/provider_test.go @@ -27,7 +27,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" ) var ( diff --git a/pkg/ext-proc/backend/vllm/metrics.go b/pkg/epp/backend/vllm/metrics.go similarity index 97% rename from pkg/ext-proc/backend/vllm/metrics.go rename to pkg/epp/backend/vllm/metrics.go index 59a132c8..8648e24c 100644 --- a/pkg/ext-proc/backend/vllm/metrics.go +++ b/pkg/epp/backend/vllm/metrics.go @@ -30,8 +30,8 @@ import ( "github.com/prometheus/common/expfmt" "go.uber.org/multierr" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( diff --git a/pkg/ext-proc/backend/vllm/metrics_test.go b/pkg/epp/backend/vllm/metrics_test.go similarity index 97% rename from pkg/ext-proc/backend/vllm/metrics_test.go rename to pkg/epp/backend/vllm/metrics_test.go index 1c9d5448..12aac1a1 100644 --- a/pkg/ext-proc/backend/vllm/metrics_test.go +++ b/pkg/epp/backend/vllm/metrics_test.go @@ -23,8 +23,8 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) func TestPromToPodMetrics(t *testing.T) { diff --git a/pkg/ext-proc/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go similarity index 95% rename from pkg/ext-proc/controller/inferencemodel_reconciler.go rename to pkg/epp/controller/inferencemodel_reconciler.go index cca05fce..99a1eb26 100644 --- a/pkg/ext-proc/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -28,8 +28,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type InferenceModelReconciler struct { diff --git a/pkg/ext-proc/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go similarity index 98% rename from pkg/ext-proc/controller/inferencemodel_reconciler_test.go rename to pkg/epp/controller/inferencemodel_reconciler_test.go index 583f5f75..cf94b168 100644 --- a/pkg/ext-proc/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -29,8 +29,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) var ( diff --git a/pkg/ext-proc/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go similarity index 96% rename from pkg/ext-proc/controller/inferencepool_reconciler.go rename to pkg/epp/controller/inferencepool_reconciler.go index b2cd01c0..f2c56991 100644 --- a/pkg/ext-proc/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -28,8 +28,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // InferencePoolReconciler utilizes the controller runtime to reconcile Instance Gateway resources diff --git a/pkg/ext-proc/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go similarity index 97% rename from pkg/ext-proc/controller/inferencepool_reconciler_test.go rename to pkg/epp/controller/inferencepool_reconciler_test.go index 925cb236..6263fa16 100644 --- a/pkg/ext-proc/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -31,8 +31,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) var ( diff --git a/pkg/ext-proc/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go similarity index 95% rename from pkg/ext-proc/controller/pod_reconciler.go rename to pkg/epp/controller/pod_reconciler.go index 871e1da5..5b0c25c9 100644 --- a/pkg/ext-proc/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -28,8 +28,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type PodReconciler struct { diff --git a/pkg/ext-proc/controller/pod_reconciler_test.go b/pkg/epp/controller/pod_reconciler_test.go similarity index 99% rename from pkg/ext-proc/controller/pod_reconciler_test.go rename to pkg/epp/controller/pod_reconciler_test.go index c87ee54d..b3869113 100644 --- a/pkg/ext-proc/controller/pod_reconciler_test.go +++ b/pkg/epp/controller/pod_reconciler_test.go @@ -32,7 +32,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" ) var ( diff --git a/pkg/ext-proc/datastore/datastore.go b/pkg/epp/datastore/datastore.go similarity index 98% rename from pkg/ext-proc/datastore/datastore.go rename to pkg/epp/datastore/datastore.go index 60236496..eecea59c 100644 --- a/pkg/ext-proc/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -29,7 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) diff --git a/pkg/ext-proc/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go similarity index 97% rename from pkg/ext-proc/datastore/datastore_test.go rename to pkg/epp/datastore/datastore_test.go index f32d8d77..bd5c5020 100644 --- a/pkg/ext-proc/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -21,7 +21,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) func TestHasSynced(t *testing.T) { diff --git a/pkg/ext-proc/datastore/types.go b/pkg/epp/datastore/types.go similarity index 100% rename from pkg/ext-proc/datastore/types.go rename to pkg/epp/datastore/types.go diff --git a/pkg/ext-proc/handlers/request.go b/pkg/epp/handlers/request.go similarity index 95% rename from pkg/ext-proc/handlers/request.go rename to pkg/epp/handlers/request.go index 34db206d..b9ffd0b0 100644 --- a/pkg/ext-proc/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -26,10 +26,10 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // HandleRequestBody handles body of the request to the backend server, such as parsing the "model" diff --git a/pkg/ext-proc/handlers/response.go b/pkg/epp/handlers/response.go similarity index 97% rename from pkg/ext-proc/handlers/response.go rename to pkg/epp/handlers/response.go index ed3082c5..f9396acf 100644 --- a/pkg/ext-proc/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -24,8 +24,8 @@ import ( configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "sigs.k8s.io/controller-runtime/pkg/log" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // HandleResponseHeaders processes response headers from the backend model server. diff --git a/pkg/ext-proc/handlers/response_test.go b/pkg/epp/handlers/response_test.go similarity index 97% rename from pkg/ext-proc/handlers/response_test.go rename to pkg/epp/handlers/response_test.go index dbb7e700..01f02d09 100644 --- a/pkg/ext-proc/handlers/response_test.go +++ b/pkg/epp/handlers/response_test.go @@ -22,7 +22,7 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/google/go-cmp/cmp" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( diff --git a/pkg/ext-proc/handlers/server.go b/pkg/epp/handlers/server.go similarity index 95% rename from pkg/ext-proc/handlers/server.go rename to pkg/epp/handlers/server.go index 506eaa97..2c61118c 100644 --- a/pkg/ext-proc/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -27,11 +27,11 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) func NewServer(scheduler Scheduler, targetEndpointKey string, datastore datastore.Datastore) *Server { @@ -185,7 +185,6 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } - } // RequestContext stores context information during the life time of an HTTP request. diff --git a/pkg/ext-proc/metrics/README.md b/pkg/epp/metrics/README.md similarity index 100% rename from pkg/ext-proc/metrics/README.md rename to pkg/epp/metrics/README.md diff --git a/pkg/ext-proc/metrics/metrics.go b/pkg/epp/metrics/metrics.go similarity index 98% rename from pkg/ext-proc/metrics/metrics.go rename to pkg/epp/metrics/metrics.go index cc21d531..e86ca901 100644 --- a/pkg/ext-proc/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -24,7 +24,7 @@ import ( compbasemetrics "k8s.io/component-base/metrics" "k8s.io/component-base/metrics/legacyregistry" "sigs.k8s.io/controller-runtime/pkg/log" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( diff --git a/pkg/ext-proc/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go similarity index 93% rename from pkg/ext-proc/metrics/metrics_test.go rename to pkg/epp/metrics/metrics_test.go index 2e891066..c2436bab 100644 --- a/pkg/ext-proc/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -24,8 +24,8 @@ import ( "k8s.io/component-base/metrics/legacyregistry" "k8s.io/component-base/metrics/testutil" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( @@ -119,31 +119,32 @@ func TestRecordRequestErrorCounter(t *testing.T) { name string reqs []requests invalid bool - }{{ - name: "multiple requests", - reqs: []requests{ - { - modelName: "m10", - targetModelName: "t10", - error: errutil.Internal, - }, - { - modelName: "m10", - targetModelName: "t10", - error: errutil.Internal, - }, - { - modelName: "m10", - targetModelName: "t11", - error: errutil.ModelServerError, - }, - { - modelName: "m20", - targetModelName: "t20", - error: errutil.InferencePoolResourceExhausted, + }{ + { + name: "multiple requests", + reqs: []requests{ + { + modelName: "m10", + targetModelName: "t10", + error: errutil.Internal, + }, + { + modelName: "m10", + targetModelName: "t10", + error: errutil.Internal, + }, + { + modelName: "m10", + targetModelName: "t11", + error: errutil.ModelServerError, + }, + { + modelName: "m20", + targetModelName: "t20", + error: errutil.InferencePoolResourceExhausted, + }, }, }, - }, } Register() for _, scenario := range scenarios { diff --git a/pkg/ext-proc/metrics/testdata/input_tokens_metric b/pkg/epp/metrics/testdata/input_tokens_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/input_tokens_metric rename to pkg/epp/metrics/testdata/input_tokens_metric diff --git a/pkg/ext-proc/metrics/testdata/kv_cache_avg_metrics b/pkg/epp/metrics/testdata/kv_cache_avg_metrics similarity index 100% rename from pkg/ext-proc/metrics/testdata/kv_cache_avg_metrics rename to pkg/epp/metrics/testdata/kv_cache_avg_metrics diff --git a/pkg/ext-proc/metrics/testdata/output_tokens_metric b/pkg/epp/metrics/testdata/output_tokens_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/output_tokens_metric rename to pkg/epp/metrics/testdata/output_tokens_metric diff --git a/pkg/ext-proc/metrics/testdata/queue_avg_size_metrics b/pkg/epp/metrics/testdata/queue_avg_size_metrics similarity index 100% rename from pkg/ext-proc/metrics/testdata/queue_avg_size_metrics rename to pkg/epp/metrics/testdata/queue_avg_size_metrics diff --git a/pkg/ext-proc/metrics/testdata/request_duration_seconds_metric b/pkg/epp/metrics/testdata/request_duration_seconds_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/request_duration_seconds_metric rename to pkg/epp/metrics/testdata/request_duration_seconds_metric diff --git a/pkg/ext-proc/metrics/testdata/request_error_total_metric b/pkg/epp/metrics/testdata/request_error_total_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/request_error_total_metric rename to pkg/epp/metrics/testdata/request_error_total_metric diff --git a/pkg/ext-proc/metrics/testdata/request_sizes_metric b/pkg/epp/metrics/testdata/request_sizes_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/request_sizes_metric rename to pkg/epp/metrics/testdata/request_sizes_metric diff --git a/pkg/ext-proc/metrics/testdata/request_total_metric b/pkg/epp/metrics/testdata/request_total_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/request_total_metric rename to pkg/epp/metrics/testdata/request_total_metric diff --git a/pkg/ext-proc/metrics/testdata/response_sizes_metric b/pkg/epp/metrics/testdata/response_sizes_metric similarity index 100% rename from pkg/ext-proc/metrics/testdata/response_sizes_metric rename to pkg/epp/metrics/testdata/response_sizes_metric diff --git a/pkg/ext-proc/scheduling/filter.go b/pkg/epp/scheduling/filter.go similarity index 98% rename from pkg/ext-proc/scheduling/filter.go rename to pkg/epp/scheduling/filter.go index 36691a73..b7881468 100644 --- a/pkg/ext-proc/scheduling/filter.go +++ b/pkg/epp/scheduling/filter.go @@ -21,8 +21,8 @@ import ( "math" "github.com/go-logr/logr" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type Filter interface { diff --git a/pkg/ext-proc/scheduling/filter_test.go b/pkg/epp/scheduling/filter_test.go similarity index 98% rename from pkg/ext-proc/scheduling/filter_test.go rename to pkg/epp/scheduling/filter_test.go index 01909fea..ac765b78 100644 --- a/pkg/ext-proc/scheduling/filter_test.go +++ b/pkg/epp/scheduling/filter_test.go @@ -23,8 +23,8 @@ import ( "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) func TestFilter(t *testing.T) { diff --git a/pkg/ext-proc/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go similarity index 94% rename from pkg/ext-proc/scheduling/scheduler.go rename to pkg/epp/scheduling/scheduler.go index b5f2f4f2..a969948e 100644 --- a/pkg/ext-proc/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -24,9 +24,9 @@ import ( "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/error" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) const ( @@ -102,7 +102,8 @@ var ( filter: func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { logger.V(logutil.DEFAULT).Info("Request dropped", "request", req) return []*datastore.PodMetrics{}, errutil.Error{ - Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources"} + Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", + } }, }, } diff --git a/pkg/ext-proc/scheduling/types.go b/pkg/epp/scheduling/types.go similarity index 100% rename from pkg/ext-proc/scheduling/types.go rename to pkg/epp/scheduling/types.go diff --git a/pkg/ext-proc/server/runserver.go b/pkg/epp/server/runserver.go similarity index 95% rename from pkg/ext-proc/server/runserver.go rename to pkg/epp/server/runserver.go index 795b242d..92b7be7f 100644 --- a/pkg/ext-proc/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -36,11 +36,11 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/controller" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/controller" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" ) // ExtProcServerRunner provides methods to manage an external process server. diff --git a/pkg/ext-proc/server/runserver_test.go b/pkg/epp/server/runserver_test.go similarity index 87% rename from pkg/ext-proc/server/runserver_test.go rename to pkg/epp/server/runserver_test.go index 438dc096..b02688c5 100644 --- a/pkg/ext-proc/server/runserver_test.go +++ b/pkg/epp/server/runserver_test.go @@ -21,8 +21,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) func TestRunnable(t *testing.T) { diff --git a/pkg/ext-proc/test/benchmark/benchmark.go b/pkg/epp/test/benchmark/benchmark.go similarity index 93% rename from pkg/ext-proc/test/benchmark/benchmark.go rename to pkg/epp/test/benchmark/benchmark.go index dc06a27a..10987b47 100644 --- a/pkg/ext-proc/test/benchmark/benchmark.go +++ b/pkg/epp/test/benchmark/benchmark.go @@ -32,10 +32,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) var ( diff --git a/pkg/ext-proc/test/utils.go b/pkg/epp/test/utils.go similarity index 88% rename from pkg/ext-proc/test/utils.go rename to pkg/epp/test/utils.go index ef83c932..f82084d9 100644 --- a/pkg/ext-proc/test/utils.go +++ b/pkg/epp/test/utils.go @@ -30,12 +30,12 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/handlers" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/scheduling" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) func StartExtProc( diff --git a/pkg/ext-proc/util/error/error.go b/pkg/epp/util/error/error.go similarity index 100% rename from pkg/ext-proc/util/error/error.go rename to pkg/epp/util/error/error.go diff --git a/pkg/ext-proc/util/logging/fatal.go b/pkg/epp/util/logging/fatal.go similarity index 100% rename from pkg/ext-proc/util/logging/fatal.go rename to pkg/epp/util/logging/fatal.go diff --git a/pkg/ext-proc/util/logging/logger.go b/pkg/epp/util/logging/logger.go similarity index 100% rename from pkg/ext-proc/util/logging/logger.go rename to pkg/epp/util/logging/logger.go diff --git a/pkg/ext-proc/util/logging/logging_const.go b/pkg/epp/util/logging/logging_const.go similarity index 100% rename from pkg/ext-proc/util/logging/logging_const.go rename to pkg/epp/util/logging/logging_const.go diff --git a/pkg/ext-proc/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go similarity index 100% rename from pkg/ext-proc/util/testing/wrappers.go rename to pkg/epp/util/testing/wrappers.go diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 18efe7bf..eb2ca40e 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -47,12 +47,12 @@ import ( k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/backend" - "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/datastore" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/server" - extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/test" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/logging" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/ext-proc/util/testing" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" "sigs.k8s.io/yaml" ) diff --git a/tools/dashboards/README.md b/tools/dashboards/README.md index c8258b63..7be2a5b8 100644 --- a/tools/dashboards/README.md +++ b/tools/dashboards/README.md @@ -4,7 +4,7 @@ This documentation provides instructions for setting up grafana dashboards to se ## Requirements -Please follow [metrics](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/ext-proc/metrics) page to configure the proxy to enable all metrics. +Please follow [metrics](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp/metrics) page to configure the proxy to enable all metrics. ## Load Inference Extension dashboard into Grafana @@ -21,6 +21,7 @@ If you run the inferece gateway with [Google Managed Prometheus](https://cloud.g Please configure the `scrape_interval` of your prometheus configuration to lower than `15s`, `rate` function returns empty string if data falls too apart. See https://www.robustperception.io/what-range-should-i-use-with-rate/ for more details. Example: + ``` global: scrape_interval: 5s diff --git a/tools/dashboards/inference_gateway.json b/tools/dashboards/inference_gateway.json index 3af66703..4e872739 100644 --- a/tools/dashboards/inference_gateway.json +++ b/tools/dashboards/inference_gateway.json @@ -39,7 +39,7 @@ "showLineNumbers": false, "showMiniMap": false }, - "content": "# Inferece Gateway Dashboard\n\nPlease see https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/ext-proc/metrics for more details of underlying metrics used in the dashboard.", + "content": "# Inferece Gateway Dashboard\n\nPlease see https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp/metrics for more details of underlying metrics used in the dashboard.", "mode": "markdown" }, "pluginVersion": "11.5.0", From a78c768d401e9e444752c89e092e7ba7fc9fc082 Mon Sep 17 00:00:00 2001 From: courageJ Date: Thu, 20 Feb 2025 19:06:27 +0000 Subject: [PATCH 039/260] Move pkg/ext-proc/metrics/README.md -> site-src/guides/metrics.md (#373) * Move pkgepp/metrics/README.md -> site-src/guides/metrics.md * add docs link for metrics.md * update formatting --- mkdocs.yml | 1 + .../README.md => site-src/guides/metrics.md | 30 ++++++++----------- 2 files changed, 14 insertions(+), 17 deletions(-) rename pkg/epp/metrics/README.md => site-src/guides/metrics.md (51%) diff --git a/mkdocs.yml b/mkdocs.yml index a024c16d..8cd3f3fb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,7 @@ nav: - User Guides: - Getting started: guides/index.md - Adapter Rollout: guides/adapter-rollout.md + - Metrics: guides/metrics.md - Implementer's Guide: guides/implementers.md - Reference: - API Reference: reference/spec.md diff --git a/pkg/epp/metrics/README.md b/site-src/guides/metrics.md similarity index 51% rename from pkg/epp/metrics/README.md rename to site-src/guides/metrics.md index 1f68a0bd..f793734d 100644 --- a/pkg/epp/metrics/README.md +++ b/site-src/guides/metrics.md @@ -1,10 +1,6 @@ -# Documentation +# Metrics -This documentation is the current state of exposed metrics. - -## Table of Contents -* [Exposed Metrics](#exposed-metrics) -* [Scrape Metrics](#scrape-metrics) +This guide describes the current state of exposed metrics and how to scrape them. ## Requirements @@ -38,17 +34,17 @@ spec: ## Exposed metrics -| Metric name | Metric Type | Description | Labels | Status | -| ------------|--------------| ----------- | ------ | ------ | -| inference_model_request_total | Counter | The counter of requests broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_model_request_error_total | Counter | The counter of requests errors broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_model_request_duration_seconds | Distribution | Distribution of response latency. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_model_request_sizes | Distribution | Distribution of request size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_model_response_sizes | Distribution | Distribution of response size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_model_input_tokens | Distribution | Distribution of input token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_model_output_tokens | Distribution | Distribution of output token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | -| inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | -| inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | +| **Metric name** | **Metric Type** |
**Description**
|
**Labels**
| **Status** | +|:---------------------------------------------|:-----------------|:------------------------------------------------------------------|:-----------------------------------------------------------------------------------|:------------| +| inference_model_request_total | Counter | The counter of requests broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_request_error_total | Counter | The counter of requests errors broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_request_duration_seconds | Distribution | Distribution of response latency. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_request_sizes | Distribution | Distribution of request size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_response_sizes | Distribution | Distribution of response size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_input_tokens | Distribution | Distribution of input token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_output_tokens | Distribution | Distribution of output token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | +| inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | ## Scrape Metrics From 2913da4595243f36536d444b279a009764dc2abe Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 21 Feb 2025 05:02:27 +0000 Subject: [PATCH 040/260] Defining an outer metadata struct as part of the extproc endpoint picking protocol (#377) * Defining an outer metadata struct as part of the extproc endpoint picking protocol * Apply suggestions from code review Update the protocol doc based on the suggested edits Co-authored-by: Lior Lieberman * Updated the flag names --------- Co-authored-by: Lior Lieberman --- cmd/epp/main.go | 34 ++++---- .../003-endpoint-picker-protocol/README.md | 24 +++++- pkg/epp/handlers/request.go | 35 +++++--- pkg/epp/handlers/server.go | 16 ++-- pkg/epp/server/runserver.go | 53 ++++++------ pkg/epp/test/utils.go | 2 +- test/integration/hermetic_test.go | 84 ++++++++----------- 7 files changed, 142 insertions(+), 106 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index a189984b..1f76cfab 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -64,10 +64,15 @@ var ( "The port used for gRPC liveness and readiness probes") metricsPort = flag.Int( "metricsPort", 9090, "The metrics port") - targetEndpointKey = flag.String( - "targetEndpointKey", - runserver.DefaultTargetEndpointKey, - "Header key used by Envoy to route to the appropriate pod. This must match Envoy configuration.") + destinationEndpointHintKey = flag.String( + "destinationEndpointHintKey", + runserver.DefaultDestinationEndpointHintKey, + "Header and response metadata key used by Envoy to route to the appropriate pod. This must match Envoy configuration.") + destinationEndpointHintMetadataNamespace = flag.String( + "DestinationEndpointHintMetadataNamespace", + runserver.DefaultDestinationEndpointHintMetadataNamespace, + "The key for the outer namespace struct in the metadata field of the extproc response that is used to wrap the"+ + "target endpoint. If not set, then an outer namespace struct should not be created.") poolName = flag.String( "poolName", runserver.DefaultPoolName, @@ -145,16 +150,17 @@ func run() error { datastore := datastore.NewDatastore() provider := backend.NewProvider(&vllm.PodMetricsClientImpl{}, datastore) serverRunner := &runserver.ExtProcServerRunner{ - GrpcPort: *grpcPort, - TargetEndpointKey: *targetEndpointKey, - PoolName: *poolName, - PoolNamespace: *poolNamespace, - RefreshMetricsInterval: *refreshMetricsInterval, - RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, - Datastore: datastore, - SecureServing: *secureServing, - CertPath: *certPath, - Provider: provider, + GrpcPort: *grpcPort, + DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, + DestinationEndpointHintKey: *destinationEndpointHintKey, + PoolName: *poolName, + PoolNamespace: *poolNamespace, + RefreshMetricsInterval: *refreshMetricsInterval, + RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, + Datastore: datastore, + SecureServing: *secureServing, + CertPath: *certPath, + Provider: provider, } if err := serverRunner.SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to setup ext-proc server") diff --git a/docs/proposals/003-endpoint-picker-protocol/README.md b/docs/proposals/003-endpoint-picker-protocol/README.md index 6876135d..418c0f3c 100644 --- a/docs/proposals/003-endpoint-picker-protocol/README.md +++ b/docs/proposals/003-endpoint-picker-protocol/README.md @@ -11,8 +11,28 @@ This is the protocol between the EPP and the proxy (e.g, Envoy). The EPP MUST implement the Envoy [external processing service](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor)protocol. -For each HTTP request, the EPP MUST communicate to the proxy the picked model server endpoint, via -adding the `x-gateway-destination-endpoint` HTTP header in the request and as an unstructured entry in the [dynamic_metadata](https://github.com/envoyproxy/go-control-plane/blob/c19bf63a811c90bf9e02f8e0dc1dcef94931ebb4/envoy/service/ext_proc/v3/external_processor.pb.go#L320) field of the ext-proc response, or otherwise return an error. The EPP MUST not set two different values in the header and the response metadata. +For each HTTP request, the EPP MUST communicate to the proxy the picked model server endpoint via: + +1. Setting the `x-gateway-destination-endpoint` HTTP header to the selected endpoint in format. + +2. Set an unstructured entry in the [dynamic_metadata](https://github.com/envoyproxy/go-control-plane/blob/c19bf63a811c90bf9e02f8e0dc1dcef94931ebb4/envoy/service/ext_proc/v3/external_processor.pb.go#L320) field of the ext-proc response. The metadata entry for the picked endpoint MUST be wrapped with an outer key (which represents the metadata namespace) with a default of `envoy.lb`. + +The final metadata necessary would look like: +```go +dynamicMetadata: { + "envoy.lb": { + "x-gateway-destination-endpoint": " + } +} +``` + +Note: +- If the EPP did not communicate the server endpoint via these two methods, it MUST return an error. +- The EPP MUST not set two different values in the header and the inner response metadata value. + +### Why envoy.lb namespace as a default? +The `envoy.lb` namesapce is a predefined namespace used for subsetting. One common way to use the selected endpoint returned from the server, is [envoy subsets](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/subsets) where host metadata for subset load balancing must be placed under `envoy.lb`. + Setting different value leads to unpredictable behavior because proxies aren't guaranteed to support both paths, and so this protocol does not define what takes precedence. ## Model Server Protocol diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index b9ffd0b0..c6cfdda2 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -119,7 +119,7 @@ func (s *Server) HandleRequestBody( headers := []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ - Key: s.targetEndpointKey, + Key: s.destinationEndpointHintKey, RawValue: []byte(endpoint), }, }, @@ -137,6 +137,29 @@ func (s *Server) HandleRequestBody( logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) } + targetEndpointValue := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + } + dynamicMetadata := targetEndpointValue + if s.destinationEndpointHintMetadataNamespace != "" { + // If a namespace is defined, wrap the selected endpoint with that. + dynamicMetadata = &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: targetEndpointValue, + }, + }, + }, + } + } + resp := &extProcPb.ProcessingResponse{ // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header // and as an unstructure ext-proc response metadata key/value pair. This enables different integration @@ -155,15 +178,7 @@ func (s *Server) HandleRequestBody( }, }, }, - DynamicMetadata: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.targetEndpointKey: { - Kind: &structpb.Value_StringValue{ - StringValue: endpoint, - }, - }, - }, - }, + DynamicMetadata: dynamicMetadata, } return resp, nil } diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 2c61118c..9105e8b1 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -34,11 +34,12 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -func NewServer(scheduler Scheduler, targetEndpointKey string, datastore datastore.Datastore) *Server { +func NewServer(scheduler Scheduler, destinationEndpointHintMetadataNamespace, destinationEndpointHintKey string, datastore datastore.Datastore) *Server { return &Server{ - scheduler: scheduler, - targetEndpointKey: targetEndpointKey, - datastore: datastore, + scheduler: scheduler, + destinationEndpointHintMetadataNamespace: destinationEndpointHintMetadataNamespace, + destinationEndpointHintKey: destinationEndpointHintKey, + datastore: datastore, } } @@ -48,8 +49,11 @@ type Server struct { scheduler Scheduler // The key of the header to specify the target pod address. This value needs to match Envoy // configuration. - targetEndpointKey string - datastore datastore.Datastore + destinationEndpointHintKey string + // The key acting as the outer namespace struct in the metadata extproc response to communicate + // back the picked endpoints. + destinationEndpointHintMetadataNamespace string + datastore datastore.Datastore } type Scheduler interface { diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 92b7be7f..6e6b68b1 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -45,38 +45,41 @@ import ( // ExtProcServerRunner provides methods to manage an external process server. type ExtProcServerRunner struct { - GrpcPort int - TargetEndpointKey string - PoolName string - PoolNamespace string - RefreshMetricsInterval time.Duration - RefreshPrometheusMetricsInterval time.Duration - Datastore datastore.Datastore - Provider *backend.Provider - SecureServing bool - CertPath string + GrpcPort int + DestinationEndpointHintMetadataNamespace string + DestinationEndpointHintKey string + PoolName string + PoolNamespace string + RefreshMetricsInterval time.Duration + RefreshPrometheusMetricsInterval time.Duration + Datastore datastore.Datastore + Provider *backend.Provider + SecureServing bool + CertPath string } // Default values for CLI flags in main const ( - DefaultGrpcPort = 9002 // default for --grpcPort - DefaultTargetEndpointKey = "x-gateway-destination-endpoint" // default for --targetEndpointKey - DefaultPoolName = "" // required but no default - DefaultPoolNamespace = "default" // default for --poolNamespace - DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval - DefaultRefreshPrometheusMetricsInterval = 5 * time.Second // default for --refreshPrometheusMetricsInterval - DefaultSecureServing = true // default for --secureServing + DefaultGrpcPort = 9002 // default for --grpcPort + DefaultDestinationEndpointHintMetadataNamespace = "envoy.lb" // default for --destinationEndpointHintMetadataNamespace + DefaultDestinationEndpointHintKey = "x-gateway-destination-endpoint" // default for --destinationEndpointHintKey + DefaultPoolName = "" // required but no default + DefaultPoolNamespace = "default" // default for --poolNamespace + DefaultRefreshMetricsInterval = 50 * time.Millisecond // default for --refreshMetricsInterval + DefaultRefreshPrometheusMetricsInterval = 5 * time.Second // default for --refreshPrometheusMetricsInterval + DefaultSecureServing = true // default for --secureServing ) func NewDefaultExtProcServerRunner() *ExtProcServerRunner { return &ExtProcServerRunner{ - GrpcPort: DefaultGrpcPort, - TargetEndpointKey: DefaultTargetEndpointKey, - PoolName: DefaultPoolName, - PoolNamespace: DefaultPoolNamespace, - RefreshMetricsInterval: DefaultRefreshMetricsInterval, - RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, - SecureServing: DefaultSecureServing, + GrpcPort: DefaultGrpcPort, + DestinationEndpointHintKey: DefaultDestinationEndpointHintKey, + DestinationEndpointHintMetadataNamespace: DefaultDestinationEndpointHintMetadataNamespace, + PoolName: DefaultPoolName, + PoolNamespace: DefaultPoolNamespace, + RefreshMetricsInterval: DefaultRefreshMetricsInterval, + RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, + SecureServing: DefaultSecureServing, // Datastore can be assigned later. } } @@ -156,7 +159,7 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { } extProcPb.RegisterExternalProcessorServer( srv, - handlers.NewServer(scheduling.NewScheduler(r.Datastore), r.TargetEndpointKey, r.Datastore), + handlers.NewServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore), ) // Forward to the gRPC runnable. diff --git a/pkg/epp/test/utils.go b/pkg/epp/test/utils.go index f82084d9..c44d7147 100644 --- a/pkg/epp/test/utils.go +++ b/pkg/epp/test/utils.go @@ -79,7 +79,7 @@ func startExtProc(logger logr.Logger, port int, datastore datastore.Datastore) * s := grpc.NewServer() - extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(scheduling.NewScheduler(datastore), "target-pod", datastore)) + extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(scheduling.NewScheduler(datastore), "", "target-pod", datastore)) logger.Info("gRPC server starting", "port", port) reflection.Register(s) diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index eb2ca40e..91bc71c6 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -100,7 +100,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ - Key: runserver.DefaultTargetEndpointKey, + Key: runserver.DefaultDestinationEndpointHintKey, RawValue: []byte("address-1:8000"), }, }, @@ -111,17 +111,9 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - runserver.DefaultTargetEndpointKey: { - Kind: &structpb.Value_StringValue{ - StringValue: "address-1:8000", - }, - }, - }, - }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), - wantErr: false, + wantMetadata: makeMetadata("address-1:8000"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), + wantErr: false, }, { name: "select active lora, low queue", @@ -156,7 +148,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ - Key: runserver.DefaultTargetEndpointKey, + Key: runserver.DefaultDestinationEndpointHintKey, RawValue: []byte("address-1:8000"), }, }, @@ -167,17 +159,9 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - runserver.DefaultTargetEndpointKey: { - Kind: &structpb.Value_StringValue{ - StringValue: "address-1:8000", - }, - }, - }, - }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), - wantErr: false, + wantMetadata: makeMetadata("address-1:8000"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), + wantErr: false, }, { name: "select no lora despite active model, avoid excessive queue size", @@ -213,7 +197,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ - Key: runserver.DefaultTargetEndpointKey, + Key: runserver.DefaultDestinationEndpointHintKey, RawValue: []byte("address-2:8000"), }, }, @@ -224,17 +208,9 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - runserver.DefaultTargetEndpointKey: { - Kind: &structpb.Value_StringValue{ - StringValue: "address-2:8000", - }, - }, - }, - }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), - wantErr: false, + wantMetadata: makeMetadata("address-2:8000"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), + wantErr: false, }, { name: "noncritical and all models past threshold, shed request", @@ -312,7 +288,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ - Key: runserver.DefaultTargetEndpointKey, + Key: runserver.DefaultDestinationEndpointHintKey, RawValue: []byte("address-0:8000"), }, }, @@ -323,17 +299,9 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - runserver.DefaultTargetEndpointKey: { - Kind: &structpb.Value_StringValue{ - StringValue: "address-0:8000", - }, - }, - }, - }, - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), - wantErr: false, + wantMetadata: makeMetadata("address-0:8000"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), + wantErr: false, }, } @@ -555,3 +523,23 @@ func readDocuments(fp string) ([][]byte, error) { } return docs, nil } + +func makeMetadata(endpoint string) *structpb.Struct { + return &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultDestinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultDestinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + }, + }, + }, + }, + } +} From 7e3cd457cdcd01339b65861c8e472cf27e6b6e80 Mon Sep 17 00:00:00 2001 From: Clayton Coleman Date: Fri, 21 Feb 2025 13:02:27 -0500 Subject: [PATCH 041/260] Draft a revised README.md (#374) Clarify the point of the project, and use the vernacular of "inference gateway" vs "ai gateway" to more succinctly explain what the distinction is. Move the website up more prominently, and describe in more detail what the immediate requirements are. Create a stub roadmap section. Add a medium complexity architecture SVG to the readme --- README.md | 27 +++++++++++++++++-------- docs/inference-gateway-architecture.svg | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 docs/inference-gateway-architecture.svg diff --git a/README.md b/README.md index a15e9542..89826f0c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,35 @@ # Gateway API Inference Extension -The Gateway API Inference Extension came out of [wg-serving](https://github.com/kubernetes/community/tree/master/wg-serving) and is sponsored by [SIG Network](https://github.com/kubernetes/community/blob/master/sig-network/README.md#gateway-api-inference-extension). This repo contains: the load balancing algorithm, [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter) code, CRDs, and controllers of the extension. +This extension upgrades an [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter)-capable proxy or gateway - such as Envoy Gateway, kGateway, or the GKE Gateway - to become an **inference gateway** - supporting inference platform teams self-hosting large language models on Kubernetes. This integration makes it easy to expose and control access to your local [OpenAI-compatible chat completion endpoints](https://platform.openai.com/docs/api-reference/chat) to other workloads on or off cluster, or to integrate your self-hosted models alongside model-as-a-service providers in a higher level **AI Gateway** like LiteLLM, Solo AI Gateway, or Apigee. -This extension is intented to provide value to multiplexed LLM services on a shared pool of compute. See the [proposal](https://github.com/kubernetes-sigs/wg-serving/tree/main/proposals/012-llm-instance-gateway) for more info. +The inference gateway: + +* Improves the tail latency and throughput of LLM completion requests against Kubernetes-hosted model servers using an extensible request scheduling alogrithm that is kv-cache and request cost aware, avoiding evictions or queueing as load increases +* Provides [Kubernetes-native declarative APIs](https://gateway-api-inference-extension.sigs.k8s.io/concepts/api-overview/) to route client model names to use-case specific LoRA adapters and control incremental rollout of new adapter versions, A/B traffic splitting, and safe blue-green base model and model server upgrades +* Adds end to end observability around service objective attainment +* Ensures operational guardrails between different client model names, allowing a platform team to safely serve many different GenAI workloads on the same pool of shared foundation model servers for higher utilization and fewer required accelerators + +![Architecture Diagram](./docs/inference-gateway-architecture.svg) + +It currently requires a version of vLLM that supports the necessary metrics to predict traffic load which is defined in the [model server protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-endpoint-picker-protocol). Support for Google's Jetstream, nVidia Triton, text-generation-inference, and SGLang is coming soon. ## Status -This project is currently in development. +This project is [alpha (0.1 release)](https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/tag/v0.1.0). It should not be used in production yet. ## Getting Started -Follow this [README](./pkg/README.md) to get the inference-extension up and running on your cluster! +Follow our [Getting Started Guide](./pkg/README.md) to get the inference-extension up and running on your cluster! -## End-to-End Tests +See our website at https://gateway-api-inference-extension.sigs.k8s.io/ for detailed API documentation on leveraging our Kubernetes-native declarative APIs -Follow this [README](./test/e2e/README.md) to learn more about running the inference-extension end-to-end test suite on your cluster. +## Roadmap + +Coming soon! -## Website +## End-to-End Tests -Detailed documentation is available on our website: https://gateway-api-inference-extension.sigs.k8s.io/ +Follow this [README](./test/e2e/README.md) to learn more about running the inference-extension end-to-end test suite on your cluster. ## Contributing diff --git a/docs/inference-gateway-architecture.svg b/docs/inference-gateway-architecture.svg new file mode 100644 index 00000000..6c887ebe --- /dev/null +++ b/docs/inference-gateway-architecture.svg @@ -0,0 +1 @@ + \ No newline at end of file From 9bd136a08cd3cf9fa0b170dbc0906e2d9dead676 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 21 Feb 2025 21:14:26 +0000 Subject: [PATCH 042/260] Add README.md file to the epp pkg (#386) * Polish the epp README.md file * Addressed comments --- ...-flowchart.png => scheduler-flowchart.png} | Bin pkg/epp/README.md | 24 ++++++++++++++++++ pkg/scheduling.md | 5 ---- 3 files changed, 24 insertions(+), 5 deletions(-) rename docs/{schedular-flowchart.png => scheduler-flowchart.png} (100%) create mode 100644 pkg/epp/README.md delete mode 100644 pkg/scheduling.md diff --git a/docs/schedular-flowchart.png b/docs/scheduler-flowchart.png similarity index 100% rename from docs/schedular-flowchart.png rename to docs/scheduler-flowchart.png diff --git a/pkg/epp/README.md b/pkg/epp/README.md new file mode 100644 index 00000000..e3bc26ae --- /dev/null +++ b/pkg/epp/README.md @@ -0,0 +1,24 @@ +# The EndPoint Picker (EPP) +This package provides the reference implementation for the Endpoint Picker (EPP). It implements the [extension protocol](../../docs/proposals/003-endpoint-picker-protocol), enabling a proxy or gateway to request endpoint hints from an extension. An EPP instance handles a single `InferencePool` (and so for each `InferencePool`, one must create a dedicated EPP deployment). + + +The Endpoint Picker performs the following core functions: + +- Endpoint Selection + - The EPP determines the appropriate Pod endpoint for the load balancer (LB) to route requests. + - It selects from the pool of ready Pods designated by the assigned InferencePool's [Selector](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/7e3cd457cdcd01339b65861c8e472cf27e6b6e80/api/v1alpha1/inferencepool_types.go#L53) field. + - Endpoint selection is contingent on the request's ModelName matching an `InferenceModel` that references the `InferencePool`. + - Requests with unmatched ModelName values trigger an error response to the proxy. +- Traffic Splitting and ModelName Rewriting + - The EPP facilitates controlled rollouts of new adapter versions by implementing traffic splitting between adapters within the same `InferencePool`, as defined by the `InferenceModel`. + - EPP rewrites the model name in the request to the [target model name](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/7e3cd457cdcd01339b65861c8e472cf27e6b6e80/api/v1alpha1/inferencemodel_types.go#L161) as defined on the `InferenceModel` object. +- Observability + - The EPP generates metrics to enhance observability. + - It reports InferenceModel-level metrics, further broken down by target model. + - Detailed information regarding metrics can be found on the [website](https://gateway-api-inference-extension.sigs.k8s.io/guides/metrics/). + +## The scheduling algorithm +The scheduling package implements request scheduling algorithms for load balancing requests across backend pods in an inference gateway. The scheduler ensures efficient resource utilization while maintaining low latency and prioritizing critical requests. It applies a series of filters based on metrics and heuristics to select the best pod for a given request. The following flow chart summarizes the current scheduling algorithm + +# Flowchart +Scheduling Algorithm diff --git a/pkg/scheduling.md b/pkg/scheduling.md deleted file mode 100644 index 99223ad2..00000000 --- a/pkg/scheduling.md +++ /dev/null @@ -1,5 +0,0 @@ -## Scheduling Package in Ext Proc -The scheduling package implements request scheduling algorithms for load balancing requests across backend pods in an inference gateway. The scheduler ensures efficient resource utilization while maintaining low latency and prioritizing critical requests. It applies a series of filters based on metrics and heuristics to select the best pod for a given request. - -# Flowchart -Scheduling Algorithm \ No newline at end of file From 616440bd8e29f4c1fcb57c6c62fb45c3a27ada96 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 21 Feb 2025 21:44:26 +0000 Subject: [PATCH 043/260] split the proxy and model server protocols for easy reference (#387) --- README.md | 2 +- .../README.md | 39 +------------------ .../004-endpoint-picker-protocol/README.md | 35 +++++++++++++++++ 3 files changed, 37 insertions(+), 39 deletions(-) rename docs/proposals/{003-endpoint-picker-protocol => 003-model-server-protocol}/README.md (54%) create mode 100644 docs/proposals/004-endpoint-picker-protocol/README.md diff --git a/README.md b/README.md index 89826f0c..e6730ae4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The inference gateway: ![Architecture Diagram](./docs/inference-gateway-architecture.svg) -It currently requires a version of vLLM that supports the necessary metrics to predict traffic load which is defined in the [model server protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-endpoint-picker-protocol). Support for Google's Jetstream, nVidia Triton, text-generation-inference, and SGLang is coming soon. +It currently requires a version of vLLM that supports the necessary metrics to predict traffic load which is defined in the [model server protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol). Support for Google's Jetstream, nVidia Triton, text-generation-inference, and SGLang is coming soon. ## Status diff --git a/docs/proposals/003-endpoint-picker-protocol/README.md b/docs/proposals/003-model-server-protocol/README.md similarity index 54% rename from docs/proposals/003-endpoint-picker-protocol/README.md rename to docs/proposals/003-model-server-protocol/README.md index 418c0f3c..44ecf4e1 100644 --- a/docs/proposals/003-endpoint-picker-protocol/README.md +++ b/docs/proposals/003-model-server-protocol/README.md @@ -1,41 +1,4 @@ -# Endpoint Picker Protocol - -The Endpoint Picker, or EPP, is a core component of the inference extension. Ultimately it's -responsible for picking an endpoint from the `InferencePool`. A reference implementation can be -found [here](../../../pkg/epp/). - -## Proxy Protocol - -This is the protocol between the EPP and the proxy (e.g, Envoy). - -The EPP MUST implement the Envoy -[external processing service](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor)protocol. - -For each HTTP request, the EPP MUST communicate to the proxy the picked model server endpoint via: - -1. Setting the `x-gateway-destination-endpoint` HTTP header to the selected endpoint in format. - -2. Set an unstructured entry in the [dynamic_metadata](https://github.com/envoyproxy/go-control-plane/blob/c19bf63a811c90bf9e02f8e0dc1dcef94931ebb4/envoy/service/ext_proc/v3/external_processor.pb.go#L320) field of the ext-proc response. The metadata entry for the picked endpoint MUST be wrapped with an outer key (which represents the metadata namespace) with a default of `envoy.lb`. - -The final metadata necessary would look like: -```go -dynamicMetadata: { - "envoy.lb": { - "x-gateway-destination-endpoint": " - } -} -``` - -Note: -- If the EPP did not communicate the server endpoint via these two methods, it MUST return an error. -- The EPP MUST not set two different values in the header and the inner response metadata value. - -### Why envoy.lb namespace as a default? -The `envoy.lb` namesapce is a predefined namespace used for subsetting. One common way to use the selected endpoint returned from the server, is [envoy subsets](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/subsets) where host metadata for subset load balancing must be placed under `envoy.lb`. - -Setting different value leads to unpredictable behavior because proxies aren't guaranteed to support both paths, and so this protocol does not define what takes precedence. - -## Model Server Protocol +# Model Server Protocol This is the protocol between the EPP and the model servers. diff --git a/docs/proposals/004-endpoint-picker-protocol/README.md b/docs/proposals/004-endpoint-picker-protocol/README.md new file mode 100644 index 00000000..1e27ff0f --- /dev/null +++ b/docs/proposals/004-endpoint-picker-protocol/README.md @@ -0,0 +1,35 @@ +# Endpoint Picker Protocol + +The Endpoint Picker, or EPP, is a core component of the inference extension. Ultimately it's +responsible for picking an endpoint from the `InferencePool`. A reference implementation can be +found [here](../../../pkg/epp/). + +This doc defines the protocol between the EPP and the proxy (e.g, Envoy). + +The EPP MUST implement the Envoy +[external processing service](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor)protocol. + +For each HTTP request, the EPP MUST communicate to the proxy the picked model server endpoint via: + +1. Setting the `x-gateway-destination-endpoint` HTTP header to the selected endpoint in format. + +2. Set an unstructured entry in the [dynamic_metadata](https://github.com/envoyproxy/go-control-plane/blob/c19bf63a811c90bf9e02f8e0dc1dcef94931ebb4/envoy/service/ext_proc/v3/external_processor.pb.go#L320) field of the ext-proc response. The metadata entry for the picked endpoint MUST be wrapped with an outer key (which represents the metadata namespace) with a default of `envoy.lb`. + +The final metadata necessary would look like: +```go +dynamicMetadata: { + "envoy.lb": { + "x-gateway-destination-endpoint": " + } +} +``` + +Note: +- If the EPP did not communicate the server endpoint via these two methods, it MUST return an error. +- The EPP MUST not set two different values in the header and the inner response metadata value. + +## Why envoy.lb namespace as a default? +The `envoy.lb` namesapce is a predefined namespace used for subsetting. One common way to use the selected endpoint returned from the server, is [envoy subsets](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/subsets) where host metadata for subset load balancing must be placed under `envoy.lb`. + +Setting different value leads to unpredictable behavior because proxies aren't guaranteed to support both paths, and so this protocol does not define what takes precedence. + From c48a4b280fc649bbeb43e72fc4389d01abe56a6c Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Fri, 21 Feb 2025 16:44:33 -0500 Subject: [PATCH 044/260] [Metric] Add inference pool and request error metrics to the dashboard (#389) --- tools/dashboards/inference_gateway.json | 905 ++++++++++++++++-------- 1 file changed, 608 insertions(+), 297 deletions(-) diff --git a/tools/dashboards/inference_gateway.json b/tools/dashboards/inference_gateway.json index 4e872739..cf00420d 100644 --- a/tools/dashboards/inference_gateway.json +++ b/tools/dashboards/inference_gateway.json @@ -28,7 +28,7 @@ }, "gridPos": { "h": 3, - "w": 23, + "w": 20, "x": 0, "y": 0 }, @@ -42,7 +42,7 @@ "content": "# Inferece Gateway Dashboard\n\nPlease see https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp/metrics for more details of underlying metrics used in the dashboard.", "mode": "markdown" }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "title": "", "type": "text" }, @@ -54,15 +54,15 @@ "x": 0, "y": 3 }, - "id": 3, + "id": 15, "panels": [], - "title": "Inference Model", + "title": "Inference Pool", "type": "row" }, { "datasource": { "type": "prometheus", - "uid": "${DS_PROMETHEUS}" + "uid": "deap2an4eadc0d" }, "fieldConfig": { "defaults": { @@ -125,7 +125,7 @@ "x": 0, "y": 4 }, - "id": 1, + "id": 16, "options": { "legend": { "calcs": [], @@ -139,33 +139,27 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, "disableTextWrap": false, "editorMode": "builder", - "exemplar": false, - "expr": "sum by(model_name, target_model_name) (rate(inference_model_request_total{}[$__rate_interval]))", + "expr": "sum by(name) (inference_pool_average_kv_cache_utilization)", "fullMetaSearch": false, "includeNullMetadata": true, - "interval": "", "legendFormat": "__auto", "range": true, "refId": "A", "useBackend": false } ], - "title": "Request / s", + "title": "Average KV Cache Utilization", "type": "timeseries" }, { "datasource": { "type": "prometheus", - "uid": "${DS_PROMETHEUS}" + "uid": "deap2an4eadc0d" }, "fieldConfig": { "defaults": { @@ -228,7 +222,7 @@ "x": 10, "y": 4 }, - "id": 2, + "id": 17, "options": { "legend": { "calcs": [], @@ -242,55 +236,36 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_request_duration_seconds_bucket{}[$__rate_interval])))", + "expr": "sum by(name) (inference_pool_average_queue_size)", "fullMetaSearch": false, - "includeNullMetadata": false, - "legendFormat": "95%", + "includeNullMetadata": true, + "legendFormat": "__auto", "range": true, "refId": "A", "useBackend": false - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_request_duration_seconds_bucket{}[$__rate_interval])))", - "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": false, - "legendFormat": "90%", - "range": true, - "refId": "B", - "useBackend": false - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_request_duration_seconds_bucket{}[$__rate_interval])))", - "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": false, - "legendFormat": "50%", - "range": true, - "refId": "C", - "useBackend": false } ], - "title": "E2E Request Latency", + "title": "Average Queue Size", "type": "timeseries" }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 12 + }, + "id": 3, + "panels": [], + "title": "Inference Model", + "type": "row" + }, { "datasource": { "type": "prometheus", @@ -353,11 +328,11 @@ }, "gridPos": { "h": 8, - "w": 10, + "w": 20, "x": 0, - "y": 12 + "y": 13 }, - "id": 6, + "id": 2, "options": { "legend": { "calcs": [], @@ -371,12 +346,12 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_request_sizes_bucket{}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_request_duration_seconds_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "includeNullMetadata": false, "legendFormat": "95%", @@ -391,7 +366,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_request_sizes_bucket{}[$__rate_interval])))", + "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_request_duration_seconds_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, "includeNullMetadata": false, @@ -407,7 +382,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_request_sizes_bucket{}[$__rate_interval])))", + "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_request_duration_seconds_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, "includeNullMetadata": false, @@ -417,7 +392,7 @@ "useBackend": false } ], - "title": "Request Size", + "title": "E2E Request Latency", "type": "timeseries" }, { @@ -483,10 +458,10 @@ "gridPos": { "h": 8, "w": 10, - "x": 10, - "y": 12 + "x": 0, + "y": 21 }, - "id": 7, + "id": 1, "options": { "legend": { "calcs": [], @@ -500,35 +475,8 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ - { - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_response_sizes_bucket{}[$__rate_interval])))", - "fullMetaSearch": false, - "includeNullMetadata": false, - "legendFormat": "95%", - "range": true, - "refId": "A", - "useBackend": false - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_response_sizes_bucket{}[$__rate_interval])))", - "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": false, - "legendFormat": "90%", - "range": true, - "refId": "B", - "useBackend": false - }, { "datasource": { "type": "prometheus", @@ -536,17 +484,18 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_response_sizes_bucket{}[$__rate_interval])))", + "exemplar": false, + "expr": "sum by(model_name, target_model_name) (rate(inference_model_request_total{}[$__rate_interval]))", "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": false, - "legendFormat": "50%", + "includeNullMetadata": true, + "interval": "", + "legendFormat": "__auto", "range": true, - "refId": "C", + "refId": "A", "useBackend": false } ], - "title": "Response Size", + "title": "Request / s", "type": "timeseries" }, { @@ -612,10 +561,10 @@ "gridPos": { "h": 8, "w": 10, - "x": 0, - "y": 20 + "x": 10, + "y": 21 }, - "id": 8, + "id": 18, "options": { "legend": { "calcs": [], @@ -629,19 +578,8 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ - { - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_input_tokens_bucket{}[$__rate_interval])))", - "fullMetaSearch": false, - "includeNullMetadata": false, - "legendFormat": "95%", - "range": true, - "refId": "A", - "useBackend": false - }, { "datasource": { "type": "prometheus", @@ -649,33 +587,18 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_input_tokens_bucket{}[$__rate_interval])))", - "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": false, - "legendFormat": "90%", - "range": true, - "refId": "B", - "useBackend": false - }, - { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" - }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_input_tokens_bucket{}[$__rate_interval])))", + "exemplar": false, + "expr": "sum by(error_code, model_name, target_model_name) (rate(inference_model_request_error_total[$__rate_interval]))", "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": false, - "legendFormat": "50%", + "includeNullMetadata": true, + "interval": "", + "legendFormat": "__auto", "range": true, - "refId": "C", + "refId": "A", "useBackend": false } ], - "title": "Input Token Count", + "title": "Request Error / s", "type": "timeseries" }, { @@ -741,10 +664,10 @@ "gridPos": { "h": 8, "w": 10, - "x": 10, - "y": 20 + "x": 0, + "y": 29 }, - "id": 9, + "id": 6, "options": { "legend": { "calcs": [], @@ -758,12 +681,12 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_output_tokens_bucket{}[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_request_sizes_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "includeNullMetadata": false, "legendFormat": "95%", @@ -778,7 +701,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_output_tokens_bucket{}[$__rate_interval])))", + "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_request_sizes_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, "includeNullMetadata": false, @@ -794,7 +717,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_output_tokens_bucket{}[$__rate_interval])))", + "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_request_sizes_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, "includeNullMetadata": false, @@ -804,22 +727,9 @@ "useBackend": false } ], - "title": "Output Token Count", + "title": "Request Size", "type": "timeseries" }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 28 - }, - "id": 10, - "panels": [], - "title": "vLLM", - "type": "row" - }, { "datasource": { "type": "prometheus", @@ -881,12 +791,12 @@ "overrides": [] }, "gridPos": { - "h": 7, + "h": 8, "w": 10, - "x": 0, + "x": 10, "y": 29 }, - "id": 14, + "id": 7, "options": { "legend": { "calcs": [], @@ -900,15 +810,15 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "sum by(model_name) (rate(vllm:prompt_tokens_total[$__rate_interval]))", + "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_response_sizes_bucket{}[$__rate_interval])))", "fullMetaSearch": false, - "includeNullMetadata": true, - "legendFormat": "Prompt Tokens/Sec", + "includeNullMetadata": false, + "legendFormat": "95%", "range": true, "refId": "A", "useBackend": false @@ -920,17 +830,33 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "sum by(model_name) (rate(vllm:generation_tokens_total[$__rate_interval]))", + "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_response_sizes_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, - "includeNullMetadata": true, - "legendFormat": "Generation Tokens/Sec", + "includeNullMetadata": false, + "legendFormat": "90%", "range": true, "refId": "B", "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_response_sizes_bucket{}[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": false, + "legendFormat": "50%", + "range": true, + "refId": "C", + "useBackend": false } ], - "title": "Token Throughput", + "title": "Response Size", "type": "timeseries" }, { @@ -994,12 +920,12 @@ "overrides": [] }, "gridPos": { - "h": 7, + "h": 8, "w": 10, - "x": 10, - "y": 29 + "x": 0, + "y": 37 }, - "id": 11, + "id": 8, "options": { "legend": { "calcs": [], @@ -1013,14 +939,14 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(vllm:e2e_request_latency_seconds_bucket[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_input_tokens_bucket{}[$__rate_interval])))", "fullMetaSearch": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "legendFormat": "95%", "range": true, "refId": "A", @@ -1033,10 +959,10 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(vllm:e2e_request_latency_seconds_bucket[$__rate_interval])))", + "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_input_tokens_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "legendFormat": "90%", "range": true, "refId": "B", @@ -1049,17 +975,17 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(vllm:e2e_request_latency_seconds_bucket[$__rate_interval])))", + "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_input_tokens_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "legendFormat": "50%", "range": true, "refId": "C", "useBackend": false } ], - "title": "E2E Request Latency", + "title": "Input Token Count", "type": "timeseries" }, { @@ -1123,12 +1049,12 @@ "overrides": [] }, "gridPos": { - "h": 7, + "h": 8, "w": 10, - "x": 0, - "y": 36 + "x": 10, + "y": 37 }, - "id": 13, + "id": 9, "options": { "legend": { "calcs": [], @@ -1142,14 +1068,14 @@ "sort": "none" } }, - "pluginVersion": "11.5.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(vllm:time_per_output_token_seconds_bucket[$__rate_interval])))", + "expr": "histogram_quantile(0.95, sum by(le) (rate(inference_model_output_tokens_bucket{}[$__rate_interval])))", "fullMetaSearch": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "legendFormat": "95%", "range": true, "refId": "A", @@ -1162,10 +1088,10 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(vllm:time_per_output_token_seconds_bucket[$__rate_interval])))", + "expr": "histogram_quantile(0.9, sum by(le) (rate(inference_model_output_tokens_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "legendFormat": "90%", "range": true, "refId": "B", @@ -1178,147 +1104,532 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(vllm:time_per_output_token_seconds_bucket[$__rate_interval])))", + "expr": "histogram_quantile(0.5, sum by(le) (rate(inference_model_output_tokens_bucket{}[$__rate_interval])))", "fullMetaSearch": false, "hide": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "legendFormat": "50%", "range": true, "refId": "C", "useBackend": false } ], - "title": "Time Per Output Token Latency", + "title": "Output Token Count", "type": "timeseries" }, { - "datasource": { - "type": "prometheus", - "uid": "${DS_PROMETHEUS}" + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 45 }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "id": 10, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 0, + "y": 52 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true }, - "thresholdsStyle": { - "mode": "off" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" } }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(model_name) (rate(vllm:prompt_tokens_total[$__rate_interval]))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Prompt Tokens/Sec", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 10, - "x": 10, - "y": 36 - }, - "id": 12, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + "disableTextWrap": false, + "editorMode": "builder", + "expr": "sum by(model_name) (rate(vllm:generation_tokens_total[$__rate_interval]))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "Generation Tokens/Sec", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "Token Throughput", + "type": "timeseries" }, - "tooltip": { - "hideZeros": false, - "mode": "single", - "sort": "none" - } - }, - "pluginVersion": "11.5.0", - "targets": [ { - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.95, sum by(le) (rate(vllm:time_to_first_token_seconds_bucket[$__rate_interval])))", - "fullMetaSearch": false, - "includeNullMetadata": true, - "legendFormat": "95%", - "range": true, - "refId": "A", - "useBackend": false + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 10, + "y": 52 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.95, sum by(le) (rate(vllm:e2e_request_latency_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "95%", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.9, sum by(le) (rate(vllm:e2e_request_latency_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "90%", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.5, sum by(le) (rate(vllm:e2e_request_latency_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "50%", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "E2E Request Latency", + "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.9, sum by(le) (rate(vllm:time_to_first_token_seconds_bucket[$__rate_interval])))", - "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": true, - "legendFormat": "90%", - "range": true, - "refId": "B", - "useBackend": false + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 0, + "y": 59 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.95, sum by(le) (rate(vllm:time_per_output_token_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "95%", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.9, sum by(le) (rate(vllm:time_per_output_token_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "90%", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.5, sum by(le) (rate(vllm:time_per_output_token_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "50%", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Time Per Output Token Latency", + "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "histogram_quantile(0.5, sum by(le) (rate(vllm:time_to_first_token_seconds_bucket[$__rate_interval])))", - "fullMetaSearch": false, - "hide": false, - "includeNullMetadata": true, - "legendFormat": "50%", - "range": true, - "refId": "C", - "useBackend": false + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 10, + "y": 59 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.95, sum by(le) (rate(vllm:time_to_first_token_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "95%", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.9, sum by(le) (rate(vllm:time_to_first_token_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "90%", + "range": true, + "refId": "B", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "histogram_quantile(0.5, sum by(le) (rate(vllm:time_to_first_token_seconds_bucket[$__rate_interval])))", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "50%", + "range": true, + "refId": "C", + "useBackend": false + } + ], + "title": "Time To First Token Latency", + "type": "timeseries" } ], - "title": "Time To First Token Latency", - "type": "timeseries" + "title": "vLLM", + "type": "row" } ], "preload": false, @@ -1350,6 +1661,6 @@ "timezone": "browser", "title": "Inference Gateway", "uid": "aeap3g4ujefb4b", - "version": 16, + "version": 20, "weekStart": "" } From 432f5ed69402e67c38b6b8a2f0e4d68baf84d701 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 21 Feb 2025 22:02:25 +0000 Subject: [PATCH 045/260] Use gcr.io/distroless/static:nonroot base image (#384) --- Dockerfile | 4 ++-- Makefile | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4adc82e4..312700bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Dockerfile has specific requirement to put this ARG at the beginning: # https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact -ARG BUILDER_IMAGE=golang:1.23-alpine -ARG BASE_IMAGE=gcr.io/distroless/base-debian10 +ARG BUILDER_IMAGE=golang:1.23 +ARG BASE_IMAGE=gcr.io/distroless/static:nonroot ## Multistage build FROM ${BUILDER_IMAGE} AS builder diff --git a/Makefile b/Makefile index 1d8fc531..8d02a5e8 100644 --- a/Makefile +++ b/Makefile @@ -36,8 +36,8 @@ SYNCER_IMAGE_NAME := lora-syncer SYNCER_IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(SYNCER_IMAGE_NAME) SYNCER_IMAGE_TAG ?= $(SYNCER_IMAGE_REPO):$(GIT_TAG) -BASE_IMAGE ?= gcr.io/distroless/base-debian10 -BUILDER_IMAGE ?= golang:1.23-alpine +BASE_IMAGE ?= gcr.io/distroless/static:nonroot +BUILDER_IMAGE ?= golang:1.23 ifdef GO_VERSION BUILDER_IMAGE = golang:$(GO_VERSION) endif From 2a615e981228aa6ffc2a89219c986ac863dde776 Mon Sep 17 00:00:00 2001 From: Kuromesi Date: Sun, 23 Feb 2025 16:06:27 +0800 Subject: [PATCH 046/260] fix context canceled recv error handling (#390) Signed-off-by: Kuromesi --- pkg/epp/handlers/server.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 9105e8b1..3270134b 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -18,7 +18,6 @@ package handlers import ( "context" - "errors" "io" "time" @@ -90,7 +89,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } req, recvErr := srv.Recv() - if recvErr == io.EOF || errors.Is(recvErr, context.Canceled) { + if recvErr == io.EOF || status.Code(recvErr) == codes.Canceled { return nil } if recvErr != nil { From 6ea3ac6b70c7ba9568316eb0bab36a2942989816 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:54:30 +0000 Subject: [PATCH 047/260] Added endpoint picker diagram (#396) --- docs/endpoint-picker.svg | 3 +++ pkg/epp/README.md | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 docs/endpoint-picker.svg diff --git a/docs/endpoint-picker.svg b/docs/endpoint-picker.svg new file mode 100644 index 00000000..3ec8eed4 --- /dev/null +++ b/docs/endpoint-picker.svg @@ -0,0 +1,3 @@ +Endpoint PickerServiceModelServerL7 Proxy / Gateway InferencePool API Selects - the model servers (the endpoints) - the endpoint picker serviceModel ServerProtocolTrafficExtensionProtocolGateway ControllerClientTrafficConfiguresWatchesWatches InferenceModel API Defines - the model/adapter to serve - the serving objectives for the modelObservabilityMetrics ScrapingObservabilityDashboardsStandard GatewayElementsInference ExtensionElementsInference Gateway \ No newline at end of file diff --git a/pkg/epp/README.md b/pkg/epp/README.md index e3bc26ae..1bf47993 100644 --- a/pkg/epp/README.md +++ b/pkg/epp/README.md @@ -1,8 +1,12 @@ # The EndPoint Picker (EPP) -This package provides the reference implementation for the Endpoint Picker (EPP). It implements the [extension protocol](../../docs/proposals/003-endpoint-picker-protocol), enabling a proxy or gateway to request endpoint hints from an extension. An EPP instance handles a single `InferencePool` (and so for each `InferencePool`, one must create a dedicated EPP deployment). +This package provides the reference implementation for the Endpoint Picker (EPP). As demonistrated in the diagram below, it implements the [extension protocol](../../docs/proposals/004-endpoint-picker-protocol), enabling a proxy or gateway to request endpoint hints from an extension, and interacts with the model servers through the defined [model server protocol](../..//docs/proposals/003-model-server-protocol). +![Architecture Diagram](../../docs/endpoint-picker.svg) -The Endpoint Picker performs the following core functions: + +## Core Functions + +An EPP instance handles a single `InferencePool` (and so for each `InferencePool`, one must create a dedicated EPP deployment), it performs the following core functions: - Endpoint Selection - The EPP determines the appropriate Pod endpoint for the load balancer (LB) to route requests. @@ -17,8 +21,8 @@ The Endpoint Picker performs the following core functions: - It reports InferenceModel-level metrics, further broken down by target model. - Detailed information regarding metrics can be found on the [website](https://gateway-api-inference-extension.sigs.k8s.io/guides/metrics/). -## The scheduling algorithm + +## Scheduling Algorithm The scheduling package implements request scheduling algorithms for load balancing requests across backend pods in an inference gateway. The scheduler ensures efficient resource utilization while maintaining low latency and prioritizing critical requests. It applies a series of filters based on metrics and heuristics to select the best pod for a given request. The following flow chart summarizes the current scheduling algorithm -# Flowchart Scheduling Algorithm From 58335c0b4fb415d44235331bb49ed61d93ce37b1 Mon Sep 17 00:00:00 2001 From: Tiger Xu / Zhonghu Xu Date: Tue, 25 Feb 2025 00:26:28 +0800 Subject: [PATCH 048/260] Added v1alpha2 api (#398) * copy api v1alpha1 to v1alpha2 * Add nested status * auto generate * use v1alpha2 --- api/v1alpha2/doc.go | 23 ++ api/v1alpha2/groupversion_info.go | 45 ++ api/v1alpha2/inferencemodel_types.go | 235 +++++++++++ api/v1alpha2/inferencepool_types.go | 255 ++++++++++++ api/v1alpha2/zz_generated.deepcopy.go | 384 ++++++++++++++++++ .../api/v1alpha2/endpointpickerconfig.go | 38 ++ .../api/v1alpha2/extension.go | 75 ++++ .../api/v1alpha2/extensionconnection.go | 42 ++ .../api/v1alpha2/extensionreference.go | 65 +++ .../api/v1alpha2/inferencemodel.go | 224 ++++++++++ .../api/v1alpha2/inferencemodelspec.go | 74 ++++ .../api/v1alpha2/inferencemodelstatus.go | 47 +++ .../api/v1alpha2/inferencepool.go | 224 ++++++++++ .../api/v1alpha2/inferencepoolspec.go | 66 +++ .../api/v1alpha2/inferencepoolstatus.go | 43 ++ .../api/v1alpha2/poolobjectreference.go | 56 +++ .../api/v1alpha2/poolstatus.go | 57 +++ .../api/v1alpha2/targetmodel.go | 47 +++ client-go/applyconfiguration/utils.go | 30 ++ client-go/clientset/versioned/clientset.go | 13 + .../versioned/fake/clientset_generated.go | 7 + .../clientset/versioned/fake/register.go | 2 + .../clientset/versioned/scheme/register.go | 2 + .../typed/api/v1alpha2/api_client.go | 111 +++++ .../versioned/typed/api/v1alpha2/doc.go | 19 + .../versioned/typed/api/v1alpha2/fake/doc.go | 19 + .../api/v1alpha2/fake/fake_api_client.go | 43 ++ .../api/v1alpha2/fake/fake_inferencemodel.go | 52 +++ .../api/v1alpha2/fake/fake_inferencepool.go | 52 +++ .../typed/api/v1alpha2/generated_expansion.go | 22 + .../typed/api/v1alpha2/inferencemodel.go | 73 ++++ .../typed/api/v1alpha2/inferencepool.go | 73 ++++ .../externalversions/api/interface.go | 8 + .../api/v1alpha2/inferencemodel.go | 89 ++++ .../api/v1alpha2/inferencepool.go | 89 ++++ .../api/v1alpha2/interface.go | 51 +++ .../informers/externalversions/generic.go | 7 + .../api/v1alpha2/expansion_generated.go | 34 ++ .../listers/api/v1alpha2/inferencemodel.go | 69 ++++ .../listers/api/v1alpha2/inferencepool.go | 69 ++++ cmd/epp/main.go | 3 + ...e.networking.x-k8s.io_inferencemodels.yaml | 224 ++++++++++ ...ce.networking.x-k8s.io_inferencepools.yaml | 252 ++++++++++++ pkg/epp/backend/fake.go | 6 +- .../controller/inferencemodel_reconciler.go | 8 +- .../inferencemodel_reconciler_test.go | 78 ++-- .../controller/inferencepool_reconciler.go | 8 +- .../inferencepool_reconciler_test.go | 18 +- pkg/epp/controller/pod_reconciler_test.go | 44 +- pkg/epp/datastore/datastore.go | 34 +- pkg/epp/datastore/datastore_test.go | 26 +- pkg/epp/test/benchmark/benchmark.go | 8 +- pkg/epp/test/utils.go | 4 +- test/e2e/e2e_suite_test.go | 4 +- test/e2e/e2e_test.go | 8 +- test/integration/hermetic_test.go | 8 +- test/utils/utils.go | 8 +- test/utils/wrappers.go | 20 +- 58 files changed, 3554 insertions(+), 141 deletions(-) create mode 100644 api/v1alpha2/doc.go create mode 100644 api/v1alpha2/groupversion_info.go create mode 100644 api/v1alpha2/inferencemodel_types.go create mode 100644 api/v1alpha2/inferencepool_types.go create mode 100644 api/v1alpha2/zz_generated.deepcopy.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/extension.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/extensionconnection.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/extensionreference.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/inferencemodel.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/inferencepool.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/poolstatus.go create mode 100644 client-go/applyconfiguration/api/v1alpha2/targetmodel.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/api_client.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/doc.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go create mode 100644 client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go create mode 100644 client-go/informers/externalversions/api/v1alpha2/inferencemodel.go create mode 100644 client-go/informers/externalversions/api/v1alpha2/inferencepool.go create mode 100644 client-go/informers/externalversions/api/v1alpha2/interface.go create mode 100644 client-go/listers/api/v1alpha2/expansion_generated.go create mode 100644 client-go/listers/api/v1alpha2/inferencemodel.go create mode 100644 client-go/listers/api/v1alpha2/inferencepool.go diff --git a/api/v1alpha2/doc.go b/api/v1alpha2/doc.go new file mode 100644 index 00000000..90a35f58 --- /dev/null +++ b/api/v1alpha2/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha2 contains API Schema definitions for the +// inference.networking.x-k8s.io API group. +// +// +k8s:openapi-gen=true +// +kubebuilder:object:generate=true +// +groupName=inference.networking.x-k8s.io +package v1alpha2 diff --git a/api/v1alpha2/groupversion_info.go b/api/v1alpha2/groupversion_info.go new file mode 100644 index 00000000..f9eb9b1e --- /dev/null +++ b/api/v1alpha2/groupversion_info.go @@ -0,0 +1,45 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha2 contains API Schema definitions for the gateway v1alpha2 API group +// +kubebuilder:object:generate=true +// +groupName=inference.networking.x-k8s.io +package v1alpha2 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "inference.networking.x-k8s.io", Version: "v1alpha2"} + + // SchemeGroupVersion is alias to GroupVersion for client-go libraries. + // It is required by pkg/client/informers/externalversions/... + SchemeGroupVersion = GroupVersion + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) + +// Resource is required by pkg/client/listers/... +func Resource(resource string) schema.GroupResource { + return GroupVersion.WithResource(resource).GroupResource() +} diff --git a/api/v1alpha2/inferencemodel_types.go b/api/v1alpha2/inferencemodel_types.go new file mode 100644 index 00000000..9ab1fd86 --- /dev/null +++ b/api/v1alpha2/inferencemodel_types.go @@ -0,0 +1,235 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// InferenceModel is the Schema for the InferenceModels API. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +genclient +type InferenceModel struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec InferenceModelSpec `json:"spec,omitempty"` + Status InferenceModelStatus `json:"status,omitempty"` +} + +// InferenceModelList contains a list of InferenceModel. +// +// +kubebuilder:object:root=true +type InferenceModelList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []InferenceModel `json:"items"` +} + +// InferenceModelSpec represents the desired state of a specific model use case. This resource is +// managed by the "Inference Workload Owner" persona. +// +// The Inference Workload Owner persona is someone that trains, verifies, and +// leverages a large language model from a model frontend, drives the lifecycle +// and rollout of new versions of those models, and defines the specific +// performance and latency goals for the model. These workloads are +// expected to operate within an InferencePool sharing compute capacity with other +// InferenceModels, defined by the Inference Platform Admin. +// +// InferenceModel's modelName (not the ObjectMeta name) is unique for a given InferencePool, +// if the name is reused, an error will be shown on the status of a +// InferenceModel that attempted to reuse. The oldest InferenceModel, based on +// creation timestamp, will be selected to remain valid. In the event of a race +// condition, one will be selected at random. +type InferenceModelSpec struct { + // ModelName is the name of the model as it will be set in the "model" parameter for an incoming request. + // ModelNames must be unique for a referencing InferencePool + // (names can be reused for a different pool in the same cluster). + // The modelName with the oldest creation timestamp is retained, and the incoming + // InferenceModel is sets the Ready status to false with a corresponding reason. + // In the rare case of a race condition, one Model will be selected randomly to be considered valid, and the other rejected. + // Names can be reserved without an underlying model configured in the pool. + // This can be done by specifying a target model and setting the weight to zero, + // an error will be returned specifying that no valid target model is found. + // + // +kubebuilder:validation:MaxLength=256 + // +kubebuilder:validation:Required + ModelName string `json:"modelName"` + + // Criticality defines how important it is to serve the model compared to other models referencing the same pool. + // Criticality impacts how traffic is handled in resource constrained situations. It handles this by + // queuing or rejecting requests of lower criticality. InferenceModels of an equivalent Criticality will + // fairly share resources over throughput of tokens. In the future, the metric used to calculate fairness, + // and the proportionality of fairness will be configurable. + // + // Default values for this field will not be set, to allow for future additions of new field that may 'one of' with this field. + // Any implementations that may consume this field may treat an unset value as the 'Standard' range. + // +optional + Criticality *Criticality `json:"criticality,omitempty"` + + // TargetModels allow multiple versions of a model for traffic splitting. + // If not specified, the target model name is defaulted to the modelName parameter. + // modelName is often in reference to a LoRA adapter. + // + // +optional + // +kubebuilder:validation:MaxItems=10 + // +kubebuilder:validation:XValidation:message="Weights should be set for all models, or none of the models.",rule="self.all(model, has(model.weight)) || self.all(model, !has(model.weight))" + TargetModels []TargetModel `json:"targetModels,omitempty"` + + // PoolRef is a reference to the inference pool, the pool must exist in the same namespace. + // + // +kubebuilder:validation:Required + PoolRef PoolObjectReference `json:"poolRef"` +} + +// PoolObjectReference identifies an API object within the namespace of the +// referrer. +type PoolObjectReference struct { + // Group is the group of the referent. + // + // +optional + // +kubebuilder:default="inference.networking.x-k8s.io" + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + Group string `json:"group,omitempty"` + + // Kind is kind of the referent. For example "InferencePool". + // + // +optional + // +kubebuilder:default="InferencePool" + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` + Kind string `json:"kind,omitempty"` + + // Name is the name of the referent. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Required + Name string `json:"name"` +} + +// Criticality defines how important it is to serve the model compared to other models. +// Criticality is intentionally a bounded enum to contain the possibilities that need to be supported by the load balancing algorithm. Any reference to the Criticality field must be optional(use a pointer), and set no default. +// This allows us to union this with a oneOf field in the future should we wish to adjust/extend this behavior. +// +kubebuilder:validation:Enum=Critical;Standard;Sheddable +type Criticality string + +const ( + // Critical defines the highest level of criticality. Requests to this band will be shed last. + Critical Criticality = "Critical" + + // Standard defines the base criticality level and is more important than Sheddable but less + // important than Critical. Requests in this band will be shed before critical traffic. + // Most models are expected to fall within this band. + Standard Criticality = "Standard" + + // Sheddable defines the lowest level of criticality. Requests to this band will be shed before + // all other bands. + Sheddable Criticality = "Sheddable" +) + +// TargetModel represents a deployed model or a LoRA adapter. The +// Name field is expected to match the name of the LoRA adapter +// (or base model) as it is registered within the model server. Inference +// Gateway assumes that the model exists on the model server and it's the +// responsibility of the user to validate a correct match. Should a model fail +// to exist at request time, the error is processed by the Inference Gateway +// and emitted on the appropriate InferenceModel object. +type TargetModel struct { + // Name is the name of the adapter or base model, as expected by the ModelServer. + // + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Required + Name string `json:"name"` + + // Weight is used to determine the proportion of traffic that should be + // sent to this model when multiple target models are specified. + // + // Weight defines the proportion of requests forwarded to the specified + // model. This is computed as weight/(sum of all weights in this + // TargetModels list). For non-zero values, there may be some epsilon from + // the exact proportion defined here depending on the precision an + // implementation supports. Weight is not a percentage and the sum of + // weights does not need to equal 100. + // + // If a weight is set for any targetModel, it must be set for all targetModels. + // Conversely weights are optional, so long as ALL targetModels do not specify a weight. + // + // +optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1000000 + Weight *int32 `json:"weight,omitempty"` +} + +// InferenceModelStatus defines the observed state of InferenceModel +type InferenceModelStatus struct { + // Conditions track the state of the InferenceModel. + // + // Known condition types are: + // + // * "Accepted" + // + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=8 + // +kubebuilder:default={{type: "Ready", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}} + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// InferenceModelConditionType is a type of condition for the InferenceModel. +type InferenceModelConditionType string + +// InferenceModelConditionReason is the reason for a given InferenceModelConditionType. +type InferenceModelConditionReason string + +const ( + // ModelConditionAccepted indicates if the model config is accepted, and if not, why. + // + // Possible reasons for this condition to be True are: + // + // * "Accepted" + // + // Possible reasons for this condition to be False are: + // + // * "ModelNameInUse" + // + // Possible reasons for this condition to be Unknown are: + // + // * "Pending" + // + ModelConditionAccepted InferenceModelConditionType = "Accepted" + + // ModelReasonAccepted is the desired state. Model conforms to the state of the pool. + ModelReasonAccepted InferenceModelConditionReason = "Accepted" + + // ModelReasonNameInUse is used when a given ModelName already exists within the pool. + // Details about naming conflict resolution are on the ModelName field itself. + ModelReasonNameInUse InferenceModelConditionReason = "ModelNameInUse" + + // ModelReasonPending is the initial state, and indicates that the controller has not yet reconciled the InferenceModel. + ModelReasonPending InferenceModelConditionReason = "Pending" +) + +func init() { + SchemeBuilder.Register(&InferenceModel{}, &InferenceModelList{}) +} diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go new file mode 100644 index 00000000..716bfb11 --- /dev/null +++ b/api/v1alpha2/inferencepool_types.go @@ -0,0 +1,255 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha2 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// InferencePool is the Schema for the InferencePools API. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +genclient +type InferencePool struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec InferencePoolSpec `json:"spec,omitempty"` + Status InferencePoolStatus `json:"status,omitempty"` +} + +// InferencePoolList contains a list of InferencePool. +// +// +kubebuilder:object:root=true +type InferencePoolList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []InferencePool `json:"items"` +} + +// InferencePoolSpec defines the desired state of InferencePool +type InferencePoolSpec struct { + // Selector defines a map of labels to watch model server pods + // that should be included in the InferencePool. + // In some cases, implementations may translate this field to a Service selector, so this matches the simple + // map used for Service selectors instead of the full Kubernetes LabelSelector type. + // + // +kubebuilder:validation:Required + Selector map[LabelKey]LabelValue `json:"selector"` + + // TargetPortNumber defines the port number to access the selected model servers. + // The number must be in the range 1 to 65535. + // + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:validation:Required + TargetPortNumber int32 `json:"targetPortNumber"` + + // EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint + // picker service that picks endpoints for the requests routed to this pool. + EndpointPickerConfig `json:",inline"` +} + +// EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint picker extension. +// This type is intended to be a union of mutually exclusive configuration options that we may add in the future. +type EndpointPickerConfig struct { + // Extension configures an endpoint picker as an extension service. + // + // +kubebuilder:validation:Required + ExtensionRef *Extension `json:"extensionRef,omitempty"` +} + +// Extension specifies how to configure an extension that runs the endpoint picker. +type Extension struct { + // Reference is a reference to a service extension. + ExtensionReference `json:",inline"` + + // ExtensionConnection configures the connection between the gateway and the extension. + ExtensionConnection `json:",inline"` +} + +// ExtensionReference is a reference to the extension deployment. +type ExtensionReference struct { + // Group is the group of the referent. + // When unspecified or empty string, core API group is inferred. + // + // +optional + // +kubebuilder:default="" + Group *string `json:"group,omitempty"` + + // Kind is the Kubernetes resource kind of the referent. For example + // "Service". + // + // Defaults to "Service" when not specified. + // + // ExternalName services can refer to CNAME DNS records that may live + // outside of the cluster and as such are difficult to reason about in + // terms of conformance. They also may not be safe to forward to (see + // CVE-2021-25740 for more information). Implementations MUST NOT + // support ExternalName Services. + // + // +optional + // +kubebuilder:default=Service + Kind *string `json:"kind,omitempty"` + + // Name is the name of the referent. + // + // +kubebuilder:validation:Required + Name string `json:"name"` + + // The port number on the service running the extension. When unspecified, implementations SHOULD infer a + // default value of 9002 when the Kind is Service. + // + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=65535 + // +optional + PortNumber *int32 `json:"targetPortNumber,omitempty"` +} + +// ExtensionConnection encapsulates options that configures the connection to the extension. +type ExtensionConnection struct { + // Configures how the gateway handles the case when the extension is not responsive. + // Defaults to failClose. + // + // +optional + // +kubebuilder:default="FailClose" + FailureMode *ExtensionFailureMode `json:"failureMode"` +} + +// ExtensionFailureMode defines the options for how the gateway handles the case when the extension is not +// responsive. +// +kubebuilder:validation:Enum=FailOpen;FailClose +type ExtensionFailureMode string + +const ( + // FailOpen specifies that the proxy should not drop the request and forward the request to and endpoint of its picking. + FailOpen ExtensionFailureMode = "FailOpen" + // FailClose specifies that the proxy should drop the request. + FailClose ExtensionFailureMode = "FailClose" +) + +// LabelKey was originally copied from: https://github.com/kubernetes-sigs/gateway-api/blob/99a3934c6bc1ce0874f3a4c5f20cafd8977ffcb4/apis/v1/shared_types.go#L694-L731 +// Duplicated as to not take an unexpected dependency on gw's API. +// +// LabelKey is the key of a label. This is used for validation +// of maps. This matches the Kubernetes "qualified name" validation that is used for labels. +// Labels are case sensitive, so: my-label and My-Label are considered distinct. +// +// Valid values include: +// +// * example +// * example.com +// * example.com/path +// * example.com/path.html +// +// Invalid values include: +// +// * example~ - "~" is an invalid character +// * example.com. - can not start or end with "." +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=253 +// +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$` +type LabelKey string + +// LabelValue is the value of a label. This is used for validation +// of maps. This matches the Kubernetes label validation rules: +// * must be 63 characters or less (can be empty), +// * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), +// * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. +// +// Valid values include: +// +// * MyValue +// * my.name +// * 123-my-value +// +// +kubebuilder:validation:MinLength=0 +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:Pattern=`^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$` +type LabelValue string + +// InferencePoolStatus defines the observed state of InferencePool +type InferencePoolStatus struct { + // Parents is a list of parent resources (usually Gateways) that are + // associated with the route, and the status of the InferencePool with respect to + // each parent. + // + // A maximum of 32 Gateways will be represented in this list. An empty list + // means the route has not been attached to any Gateway. + // + // +kubebuilder:validation:MaxItems=32 + Parents []PoolStatus `json:"parent,omitempty"` +} + +// PoolStatus defines the observed state of InferencePool from a gateway. +type PoolStatus struct { + // GatewayRef indicates the gateway that observed state of InferencePool. + GatewayRef corev1.ObjectReference `json:"parentRef"` + // Conditions track the state of the InferencePool. + // + // Known condition types are: + // + // * "Ready" + // + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=8 + // +kubebuilder:default={{type: "Ready", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}} + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// InferencePoolConditionType is a type of condition for the InferencePool +type InferencePoolConditionType string + +// InferencePoolConditionReason is the reason for a given InferencePoolConditionType +type InferencePoolConditionReason string + +const ( + // PoolConditionReady indicates if the pool is ready to accept traffic, and if not, why. + // + // Possible reasons for this condition to be True are: + // + // * "Ready" + // + // Possible reasons for this condition to be False are: + // + // * "EndpointPickerNotHealthy" + // + // Possible reasons for this condition to be Unknown are: + // + // * "Pending" + // + PoolConditionReady InferencePoolConditionType = "Ready" + + // PoolReasonReady is the desired state. The pool and its components are initialized and ready for traffic. + PoolReasonReady InferencePoolConditionReason = "Ready" + + // PoolReasonEPPNotHealthy is used when the EPP has not yet passed health checks, or has started failing them. + PoolReasonEPPNotHealthy InferencePoolConditionReason = "EndpointPickerNotHealthy" + + // PoolReasonPending is the initial state, and indicates that the controller has not yet reconciled this pool. + PoolReasonPending InferencePoolConditionReason = "Pending" +) + +func init() { + SchemeBuilder.Register(&InferencePool{}, &InferencePoolList{}) +} diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go new file mode 100644 index 00000000..9b685969 --- /dev/null +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -0,0 +1,384 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2024 The Kubernetes 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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EndpointPickerConfig) DeepCopyInto(out *EndpointPickerConfig) { + *out = *in + if in.ExtensionRef != nil { + in, out := &in.ExtensionRef, &out.ExtensionRef + *out = new(Extension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointPickerConfig. +func (in *EndpointPickerConfig) DeepCopy() *EndpointPickerConfig { + if in == nil { + return nil + } + out := new(EndpointPickerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Extension) DeepCopyInto(out *Extension) { + *out = *in + in.ExtensionReference.DeepCopyInto(&out.ExtensionReference) + in.ExtensionConnection.DeepCopyInto(&out.ExtensionConnection) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Extension. +func (in *Extension) DeepCopy() *Extension { + if in == nil { + return nil + } + out := new(Extension) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtensionConnection) DeepCopyInto(out *ExtensionConnection) { + *out = *in + if in.FailureMode != nil { + in, out := &in.FailureMode, &out.FailureMode + *out = new(ExtensionFailureMode) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtensionConnection. +func (in *ExtensionConnection) DeepCopy() *ExtensionConnection { + if in == nil { + return nil + } + out := new(ExtensionConnection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtensionReference) DeepCopyInto(out *ExtensionReference) { + *out = *in + if in.Group != nil { + in, out := &in.Group, &out.Group + *out = new(string) + **out = **in + } + if in.Kind != nil { + in, out := &in.Kind, &out.Kind + *out = new(string) + **out = **in + } + if in.PortNumber != nil { + in, out := &in.PortNumber, &out.PortNumber + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtensionReference. +func (in *ExtensionReference) DeepCopy() *ExtensionReference { + if in == nil { + return nil + } + out := new(ExtensionReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceModel) DeepCopyInto(out *InferenceModel) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModel. +func (in *InferenceModel) DeepCopy() *InferenceModel { + if in == nil { + return nil + } + out := new(InferenceModel) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InferenceModel) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceModelList) DeepCopyInto(out *InferenceModelList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]InferenceModel, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModelList. +func (in *InferenceModelList) DeepCopy() *InferenceModelList { + if in == nil { + return nil + } + out := new(InferenceModelList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InferenceModelList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceModelSpec) DeepCopyInto(out *InferenceModelSpec) { + *out = *in + if in.Criticality != nil { + in, out := &in.Criticality, &out.Criticality + *out = new(Criticality) + **out = **in + } + if in.TargetModels != nil { + in, out := &in.TargetModels, &out.TargetModels + *out = make([]TargetModel, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.PoolRef = in.PoolRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModelSpec. +func (in *InferenceModelSpec) DeepCopy() *InferenceModelSpec { + if in == nil { + return nil + } + out := new(InferenceModelSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferenceModelStatus) DeepCopyInto(out *InferenceModelStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModelStatus. +func (in *InferenceModelStatus) DeepCopy() *InferenceModelStatus { + if in == nil { + return nil + } + out := new(InferenceModelStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferencePool) DeepCopyInto(out *InferencePool) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePool. +func (in *InferencePool) DeepCopy() *InferencePool { + if in == nil { + return nil + } + out := new(InferencePool) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InferencePool) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferencePoolList) DeepCopyInto(out *InferencePoolList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]InferencePool, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePoolList. +func (in *InferencePoolList) DeepCopy() *InferencePoolList { + if in == nil { + return nil + } + out := new(InferencePoolList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InferencePoolList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferencePoolSpec) DeepCopyInto(out *InferencePoolSpec) { + *out = *in + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = make(map[LabelKey]LabelValue, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.EndpointPickerConfig.DeepCopyInto(&out.EndpointPickerConfig) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePoolSpec. +func (in *InferencePoolSpec) DeepCopy() *InferencePoolSpec { + if in == nil { + return nil + } + out := new(InferencePoolSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InferencePoolStatus) DeepCopyInto(out *InferencePoolStatus) { + *out = *in + if in.Parents != nil { + in, out := &in.Parents, &out.Parents + *out = make([]PoolStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePoolStatus. +func (in *InferencePoolStatus) DeepCopy() *InferencePoolStatus { + if in == nil { + return nil + } + out := new(InferencePoolStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PoolObjectReference) DeepCopyInto(out *PoolObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PoolObjectReference. +func (in *PoolObjectReference) DeepCopy() *PoolObjectReference { + if in == nil { + return nil + } + out := new(PoolObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PoolStatus) DeepCopyInto(out *PoolStatus) { + *out = *in + out.GatewayRef = in.GatewayRef + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PoolStatus. +func (in *PoolStatus) DeepCopy() *PoolStatus { + if in == nil { + return nil + } + out := new(PoolStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TargetModel) DeepCopyInto(out *TargetModel) { + *out = *in + if in.Weight != nil { + in, out := &in.Weight, &out.Weight + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TargetModel. +func (in *TargetModel) DeepCopy() *TargetModel { + if in == nil { + return nil + } + out := new(TargetModel) + in.DeepCopyInto(out) + return out +} diff --git a/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go b/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go new file mode 100644 index 00000000..007b8870 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go @@ -0,0 +1,38 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +// EndpointPickerConfigApplyConfiguration represents a declarative configuration of the EndpointPickerConfig type for use +// with apply. +type EndpointPickerConfigApplyConfiguration struct { + ExtensionRef *ExtensionApplyConfiguration `json:"extensionRef,omitempty"` +} + +// EndpointPickerConfigApplyConfiguration constructs a declarative configuration of the EndpointPickerConfig type for use with +// apply. +func EndpointPickerConfig() *EndpointPickerConfigApplyConfiguration { + return &EndpointPickerConfigApplyConfiguration{} +} + +// WithExtensionRef sets the ExtensionRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ExtensionRef field is set to the value of the last call. +func (b *EndpointPickerConfigApplyConfiguration) WithExtensionRef(value *ExtensionApplyConfiguration) *EndpointPickerConfigApplyConfiguration { + b.ExtensionRef = value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/extension.go b/client-go/applyconfiguration/api/v1alpha2/extension.go new file mode 100644 index 00000000..b3802613 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/extension.go @@ -0,0 +1,75 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// ExtensionApplyConfiguration represents a declarative configuration of the Extension type for use +// with apply. +type ExtensionApplyConfiguration struct { + ExtensionReferenceApplyConfiguration `json:",inline"` + ExtensionConnectionApplyConfiguration `json:",inline"` +} + +// ExtensionApplyConfiguration constructs a declarative configuration of the Extension type for use with +// apply. +func Extension() *ExtensionApplyConfiguration { + return &ExtensionApplyConfiguration{} +} + +// WithGroup sets the Group field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Group field is set to the value of the last call. +func (b *ExtensionApplyConfiguration) WithGroup(value string) *ExtensionApplyConfiguration { + b.ExtensionReferenceApplyConfiguration.Group = &value + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *ExtensionApplyConfiguration) WithKind(value string) *ExtensionApplyConfiguration { + b.ExtensionReferenceApplyConfiguration.Kind = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *ExtensionApplyConfiguration) WithName(value string) *ExtensionApplyConfiguration { + b.ExtensionReferenceApplyConfiguration.Name = &value + return b +} + +// WithPortNumber sets the PortNumber field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PortNumber field is set to the value of the last call. +func (b *ExtensionApplyConfiguration) WithPortNumber(value int32) *ExtensionApplyConfiguration { + b.ExtensionReferenceApplyConfiguration.PortNumber = &value + return b +} + +// WithFailureMode sets the FailureMode field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the FailureMode field is set to the value of the last call. +func (b *ExtensionApplyConfiguration) WithFailureMode(value apiv1alpha2.ExtensionFailureMode) *ExtensionApplyConfiguration { + b.ExtensionConnectionApplyConfiguration.FailureMode = &value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go b/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go new file mode 100644 index 00000000..2a59b830 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go @@ -0,0 +1,42 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// ExtensionConnectionApplyConfiguration represents a declarative configuration of the ExtensionConnection type for use +// with apply. +type ExtensionConnectionApplyConfiguration struct { + FailureMode *apiv1alpha2.ExtensionFailureMode `json:"failureMode,omitempty"` +} + +// ExtensionConnectionApplyConfiguration constructs a declarative configuration of the ExtensionConnection type for use with +// apply. +func ExtensionConnection() *ExtensionConnectionApplyConfiguration { + return &ExtensionConnectionApplyConfiguration{} +} + +// WithFailureMode sets the FailureMode field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the FailureMode field is set to the value of the last call. +func (b *ExtensionConnectionApplyConfiguration) WithFailureMode(value apiv1alpha2.ExtensionFailureMode) *ExtensionConnectionApplyConfiguration { + b.FailureMode = &value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/extensionreference.go b/client-go/applyconfiguration/api/v1alpha2/extensionreference.go new file mode 100644 index 00000000..71034710 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/extensionreference.go @@ -0,0 +1,65 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +// ExtensionReferenceApplyConfiguration represents a declarative configuration of the ExtensionReference type for use +// with apply. +type ExtensionReferenceApplyConfiguration struct { + Group *string `json:"group,omitempty"` + Kind *string `json:"kind,omitempty"` + Name *string `json:"name,omitempty"` + PortNumber *int32 `json:"targetPortNumber,omitempty"` +} + +// ExtensionReferenceApplyConfiguration constructs a declarative configuration of the ExtensionReference type for use with +// apply. +func ExtensionReference() *ExtensionReferenceApplyConfiguration { + return &ExtensionReferenceApplyConfiguration{} +} + +// WithGroup sets the Group field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Group field is set to the value of the last call. +func (b *ExtensionReferenceApplyConfiguration) WithGroup(value string) *ExtensionReferenceApplyConfiguration { + b.Group = &value + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *ExtensionReferenceApplyConfiguration) WithKind(value string) *ExtensionReferenceApplyConfiguration { + b.Kind = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *ExtensionReferenceApplyConfiguration) WithName(value string) *ExtensionReferenceApplyConfiguration { + b.Name = &value + return b +} + +// WithPortNumber sets the PortNumber field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PortNumber field is set to the value of the last call. +func (b *ExtensionReferenceApplyConfiguration) WithPortNumber(value int32) *ExtensionReferenceApplyConfiguration { + b.PortNumber = &value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go b/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go new file mode 100644 index 00000000..1fbfe106 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go @@ -0,0 +1,224 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// InferenceModelApplyConfiguration represents a declarative configuration of the InferenceModel type for use +// with apply. +type InferenceModelApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *InferenceModelSpecApplyConfiguration `json:"spec,omitempty"` + Status *InferenceModelStatusApplyConfiguration `json:"status,omitempty"` +} + +// InferenceModel constructs a declarative configuration of the InferenceModel type for use with +// apply. +func InferenceModel(name, namespace string) *InferenceModelApplyConfiguration { + b := &InferenceModelApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("InferenceModel") + b.WithAPIVersion("inference.networking.x-k8s.io/v1alpha2") + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithKind(value string) *InferenceModelApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithAPIVersion(value string) *InferenceModelApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithName(value string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithGenerateName(value string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithNamespace(value string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithUID(value types.UID) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithResourceVersion(value string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithGeneration(value int64) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithCreationTimestamp(value metav1.Time) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *InferenceModelApplyConfiguration) WithLabels(entries map[string]string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *InferenceModelApplyConfiguration) WithAnnotations(entries map[string]string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *InferenceModelApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *InferenceModelApplyConfiguration) WithFinalizers(values ...string) *InferenceModelApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *InferenceModelApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithSpec(value *InferenceModelSpecApplyConfiguration) *InferenceModelApplyConfiguration { + b.Spec = value + return b +} + +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *InferenceModelApplyConfiguration) WithStatus(value *InferenceModelStatusApplyConfiguration) *InferenceModelApplyConfiguration { + b.Status = value + return b +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *InferenceModelApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go b/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go new file mode 100644 index 00000000..438ccd48 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go @@ -0,0 +1,74 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// InferenceModelSpecApplyConfiguration represents a declarative configuration of the InferenceModelSpec type for use +// with apply. +type InferenceModelSpecApplyConfiguration struct { + ModelName *string `json:"modelName,omitempty"` + Criticality *apiv1alpha2.Criticality `json:"criticality,omitempty"` + TargetModels []TargetModelApplyConfiguration `json:"targetModels,omitempty"` + PoolRef *PoolObjectReferenceApplyConfiguration `json:"poolRef,omitempty"` +} + +// InferenceModelSpecApplyConfiguration constructs a declarative configuration of the InferenceModelSpec type for use with +// apply. +func InferenceModelSpec() *InferenceModelSpecApplyConfiguration { + return &InferenceModelSpecApplyConfiguration{} +} + +// WithModelName sets the ModelName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ModelName field is set to the value of the last call. +func (b *InferenceModelSpecApplyConfiguration) WithModelName(value string) *InferenceModelSpecApplyConfiguration { + b.ModelName = &value + return b +} + +// WithCriticality sets the Criticality field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Criticality field is set to the value of the last call. +func (b *InferenceModelSpecApplyConfiguration) WithCriticality(value apiv1alpha2.Criticality) *InferenceModelSpecApplyConfiguration { + b.Criticality = &value + return b +} + +// WithTargetModels adds the given value to the TargetModels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TargetModels field. +func (b *InferenceModelSpecApplyConfiguration) WithTargetModels(values ...*TargetModelApplyConfiguration) *InferenceModelSpecApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithTargetModels") + } + b.TargetModels = append(b.TargetModels, *values[i]) + } + return b +} + +// WithPoolRef sets the PoolRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PoolRef field is set to the value of the last call. +func (b *InferenceModelSpecApplyConfiguration) WithPoolRef(value *PoolObjectReferenceApplyConfiguration) *InferenceModelSpecApplyConfiguration { + b.PoolRef = value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go b/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go new file mode 100644 index 00000000..e8142efe --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go @@ -0,0 +1,47 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// InferenceModelStatusApplyConfiguration represents a declarative configuration of the InferenceModelStatus type for use +// with apply. +type InferenceModelStatusApplyConfiguration struct { + Conditions []v1.ConditionApplyConfiguration `json:"conditions,omitempty"` +} + +// InferenceModelStatusApplyConfiguration constructs a declarative configuration of the InferenceModelStatus type for use with +// apply. +func InferenceModelStatus() *InferenceModelStatusApplyConfiguration { + return &InferenceModelStatusApplyConfiguration{} +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *InferenceModelStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *InferenceModelStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.Conditions = append(b.Conditions, *values[i]) + } + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencepool.go b/client-go/applyconfiguration/api/v1alpha2/inferencepool.go new file mode 100644 index 00000000..cd725cb6 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/inferencepool.go @@ -0,0 +1,224 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// InferencePoolApplyConfiguration represents a declarative configuration of the InferencePool type for use +// with apply. +type InferencePoolApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *InferencePoolSpecApplyConfiguration `json:"spec,omitempty"` + Status *InferencePoolStatusApplyConfiguration `json:"status,omitempty"` +} + +// InferencePool constructs a declarative configuration of the InferencePool type for use with +// apply. +func InferencePool(name, namespace string) *InferencePoolApplyConfiguration { + b := &InferencePoolApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("InferencePool") + b.WithAPIVersion("inference.networking.x-k8s.io/v1alpha2") + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithKind(value string) *InferencePoolApplyConfiguration { + b.TypeMetaApplyConfiguration.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithAPIVersion(value string) *InferencePoolApplyConfiguration { + b.TypeMetaApplyConfiguration.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithName(value string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithGenerateName(value string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithNamespace(value string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithUID(value types.UID) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithResourceVersion(value string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithGeneration(value int64) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithCreationTimestamp(value metav1.Time) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *InferencePoolApplyConfiguration) WithLabels(entries map[string]string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *InferencePoolApplyConfiguration) WithAnnotations(entries map[string]string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { + b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.ObjectMetaApplyConfiguration.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *InferencePoolApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *InferencePoolApplyConfiguration) WithFinalizers(values ...string) *InferencePoolApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) + } + return b +} + +func (b *InferencePoolApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithSpec(value *InferencePoolSpecApplyConfiguration) *InferencePoolApplyConfiguration { + b.Spec = value + return b +} + +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *InferencePoolApplyConfiguration) WithStatus(value *InferencePoolStatusApplyConfiguration) *InferencePoolApplyConfiguration { + b.Status = value + return b +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *InferencePoolApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.ObjectMetaApplyConfiguration.Name +} diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go b/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go new file mode 100644 index 00000000..e4d5a97d --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go @@ -0,0 +1,66 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// InferencePoolSpecApplyConfiguration represents a declarative configuration of the InferencePoolSpec type for use +// with apply. +type InferencePoolSpecApplyConfiguration struct { + Selector map[apiv1alpha2.LabelKey]apiv1alpha2.LabelValue `json:"selector,omitempty"` + TargetPortNumber *int32 `json:"targetPortNumber,omitempty"` + EndpointPickerConfigApplyConfiguration `json:",inline"` +} + +// InferencePoolSpecApplyConfiguration constructs a declarative configuration of the InferencePoolSpec type for use with +// apply. +func InferencePoolSpec() *InferencePoolSpecApplyConfiguration { + return &InferencePoolSpecApplyConfiguration{} +} + +// WithSelector puts the entries into the Selector field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Selector field, +// overwriting an existing map entries in Selector field with the same key. +func (b *InferencePoolSpecApplyConfiguration) WithSelector(entries map[apiv1alpha2.LabelKey]apiv1alpha2.LabelValue) *InferencePoolSpecApplyConfiguration { + if b.Selector == nil && len(entries) > 0 { + b.Selector = make(map[apiv1alpha2.LabelKey]apiv1alpha2.LabelValue, len(entries)) + } + for k, v := range entries { + b.Selector[k] = v + } + return b +} + +// WithTargetPortNumber sets the TargetPortNumber field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the TargetPortNumber field is set to the value of the last call. +func (b *InferencePoolSpecApplyConfiguration) WithTargetPortNumber(value int32) *InferencePoolSpecApplyConfiguration { + b.TargetPortNumber = &value + return b +} + +// WithExtensionRef sets the ExtensionRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ExtensionRef field is set to the value of the last call. +func (b *InferencePoolSpecApplyConfiguration) WithExtensionRef(value *ExtensionApplyConfiguration) *InferencePoolSpecApplyConfiguration { + b.EndpointPickerConfigApplyConfiguration.ExtensionRef = value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go b/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go new file mode 100644 index 00000000..9587dabe --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +// InferencePoolStatusApplyConfiguration represents a declarative configuration of the InferencePoolStatus type for use +// with apply. +type InferencePoolStatusApplyConfiguration struct { + Parents []PoolStatusApplyConfiguration `json:"parent,omitempty"` +} + +// InferencePoolStatusApplyConfiguration constructs a declarative configuration of the InferencePoolStatus type for use with +// apply. +func InferencePoolStatus() *InferencePoolStatusApplyConfiguration { + return &InferencePoolStatusApplyConfiguration{} +} + +// WithParents adds the given value to the Parents field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Parents field. +func (b *InferencePoolStatusApplyConfiguration) WithParents(values ...*PoolStatusApplyConfiguration) *InferencePoolStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithParents") + } + b.Parents = append(b.Parents, *values[i]) + } + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go b/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go new file mode 100644 index 00000000..cc88c950 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go @@ -0,0 +1,56 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +// PoolObjectReferenceApplyConfiguration represents a declarative configuration of the PoolObjectReference type for use +// with apply. +type PoolObjectReferenceApplyConfiguration struct { + Group *string `json:"group,omitempty"` + Kind *string `json:"kind,omitempty"` + Name *string `json:"name,omitempty"` +} + +// PoolObjectReferenceApplyConfiguration constructs a declarative configuration of the PoolObjectReference type for use with +// apply. +func PoolObjectReference() *PoolObjectReferenceApplyConfiguration { + return &PoolObjectReferenceApplyConfiguration{} +} + +// WithGroup sets the Group field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Group field is set to the value of the last call. +func (b *PoolObjectReferenceApplyConfiguration) WithGroup(value string) *PoolObjectReferenceApplyConfiguration { + b.Group = &value + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *PoolObjectReferenceApplyConfiguration) WithKind(value string) *PoolObjectReferenceApplyConfiguration { + b.Kind = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *PoolObjectReferenceApplyConfiguration) WithName(value string) *PoolObjectReferenceApplyConfiguration { + b.Name = &value + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/poolstatus.go b/client-go/applyconfiguration/api/v1alpha2/poolstatus.go new file mode 100644 index 00000000..bff29935 --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/poolstatus.go @@ -0,0 +1,57 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// PoolStatusApplyConfiguration represents a declarative configuration of the PoolStatus type for use +// with apply. +type PoolStatusApplyConfiguration struct { + GatewayRef *v1.ObjectReference `json:"parentRef,omitempty"` + Conditions []metav1.ConditionApplyConfiguration `json:"conditions,omitempty"` +} + +// PoolStatusApplyConfiguration constructs a declarative configuration of the PoolStatus type for use with +// apply. +func PoolStatus() *PoolStatusApplyConfiguration { + return &PoolStatusApplyConfiguration{} +} + +// WithGatewayRef sets the GatewayRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GatewayRef field is set to the value of the last call. +func (b *PoolStatusApplyConfiguration) WithGatewayRef(value v1.ObjectReference) *PoolStatusApplyConfiguration { + b.GatewayRef = &value + return b +} + +// WithConditions adds the given value to the Conditions field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Conditions field. +func (b *PoolStatusApplyConfiguration) WithConditions(values ...*metav1.ConditionApplyConfiguration) *PoolStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithConditions") + } + b.Conditions = append(b.Conditions, *values[i]) + } + return b +} diff --git a/client-go/applyconfiguration/api/v1alpha2/targetmodel.go b/client-go/applyconfiguration/api/v1alpha2/targetmodel.go new file mode 100644 index 00000000..4ed9b4bc --- /dev/null +++ b/client-go/applyconfiguration/api/v1alpha2/targetmodel.go @@ -0,0 +1,47 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1alpha2 + +// TargetModelApplyConfiguration represents a declarative configuration of the TargetModel type for use +// with apply. +type TargetModelApplyConfiguration struct { + Name *string `json:"name,omitempty"` + Weight *int32 `json:"weight,omitempty"` +} + +// TargetModelApplyConfiguration constructs a declarative configuration of the TargetModel type for use with +// apply. +func TargetModel() *TargetModelApplyConfiguration { + return &TargetModelApplyConfiguration{} +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *TargetModelApplyConfiguration) WithName(value string) *TargetModelApplyConfiguration { + b.Name = &value + return b +} + +// WithWeight sets the Weight field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Weight field is set to the value of the last call. +func (b *TargetModelApplyConfiguration) WithWeight(value int32) *TargetModelApplyConfiguration { + b.Weight = &value + return b +} diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index 677fa6e3..eacc9c43 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -22,7 +22,9 @@ import ( schema "k8s.io/apimachinery/pkg/runtime/schema" testing "k8s.io/client-go/testing" v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha2" internal "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/internal" ) @@ -56,6 +58,34 @@ func ForKind(kind schema.GroupVersionKind) interface{} { case v1alpha1.SchemeGroupVersion.WithKind("TargetModel"): return &apiv1alpha1.TargetModelApplyConfiguration{} + // Group=inference.networking.x-k8s.io, Version=v1alpha2 + case v1alpha2.SchemeGroupVersion.WithKind("EndpointPickerConfig"): + return &apiv1alpha2.EndpointPickerConfigApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("Extension"): + return &apiv1alpha2.ExtensionApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("ExtensionConnection"): + return &apiv1alpha2.ExtensionConnectionApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("ExtensionReference"): + return &apiv1alpha2.ExtensionReferenceApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("InferenceModel"): + return &apiv1alpha2.InferenceModelApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("InferenceModelSpec"): + return &apiv1alpha2.InferenceModelSpecApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("InferenceModelStatus"): + return &apiv1alpha2.InferenceModelStatusApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("InferencePool"): + return &apiv1alpha2.InferencePoolApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("InferencePoolSpec"): + return &apiv1alpha2.InferencePoolSpecApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("InferencePoolStatus"): + return &apiv1alpha2.InferencePoolStatusApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("PoolObjectReference"): + return &apiv1alpha2.PoolObjectReferenceApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("PoolStatus"): + return &apiv1alpha2.PoolStatusApplyConfiguration{} + case v1alpha2.SchemeGroupVersion.WithKind("TargetModel"): + return &apiv1alpha2.TargetModelApplyConfiguration{} + } return nil } diff --git a/client-go/clientset/versioned/clientset.go b/client-go/clientset/versioned/clientset.go index b7ebc1d8..4266285a 100644 --- a/client-go/clientset/versioned/clientset.go +++ b/client-go/clientset/versioned/clientset.go @@ -25,17 +25,20 @@ import ( rest "k8s.io/client-go/rest" flowcontrol "k8s.io/client-go/util/flowcontrol" inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" + inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" ) type Interface interface { Discovery() discovery.DiscoveryInterface InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface + InferenceV1alpha2() inferencev1alpha2.InferenceV1alpha2Interface } // Clientset contains the clients for groups. type Clientset struct { *discovery.DiscoveryClient inferenceV1alpha1 *inferencev1alpha1.InferenceV1alpha1Client + inferenceV1alpha2 *inferencev1alpha2.InferenceV1alpha2Client } // InferenceV1alpha1 retrieves the InferenceV1alpha1Client @@ -43,6 +46,11 @@ func (c *Clientset) InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Inter return c.inferenceV1alpha1 } +// InferenceV1alpha2 retrieves the InferenceV1alpha2Client +func (c *Clientset) InferenceV1alpha2() inferencev1alpha2.InferenceV1alpha2Interface { + return c.inferenceV1alpha2 +} + // Discovery retrieves the DiscoveryClient func (c *Clientset) Discovery() discovery.DiscoveryInterface { if c == nil { @@ -91,6 +99,10 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, if err != nil { return nil, err } + cs.inferenceV1alpha2, err = inferencev1alpha2.NewForConfigAndClient(&configShallowCopy, httpClient) + if err != nil { + return nil, err + } cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfigAndClient(&configShallowCopy, httpClient) if err != nil { @@ -113,6 +125,7 @@ func NewForConfigOrDie(c *rest.Config) *Clientset { func New(c rest.Interface) *Clientset { var cs Clientset cs.inferenceV1alpha1 = inferencev1alpha1.New(c) + cs.inferenceV1alpha2 = inferencev1alpha2.New(c) cs.DiscoveryClient = discovery.NewDiscoveryClient(c) return &cs diff --git a/client-go/clientset/versioned/fake/clientset_generated.go b/client-go/clientset/versioned/fake/clientset_generated.go index 1e54db31..f4f33032 100644 --- a/client-go/clientset/versioned/fake/clientset_generated.go +++ b/client-go/clientset/versioned/fake/clientset_generated.go @@ -27,6 +27,8 @@ import ( clientset "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" fakeinferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1/fake" + inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" + fakeinferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2/fake" ) // NewSimpleClientset returns a clientset that will respond with the provided objects. @@ -119,3 +121,8 @@ var ( func (c *Clientset) InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface { return &fakeinferencev1alpha1.FakeInferenceV1alpha1{Fake: &c.Fake} } + +// InferenceV1alpha2 retrieves the InferenceV1alpha2Client +func (c *Clientset) InferenceV1alpha2() inferencev1alpha2.InferenceV1alpha2Interface { + return &fakeinferencev1alpha2.FakeInferenceV1alpha2{Fake: &c.Fake} +} diff --git a/client-go/clientset/versioned/fake/register.go b/client-go/clientset/versioned/fake/register.go index b72a8ce3..bc8e6903 100644 --- a/client-go/clientset/versioned/fake/register.go +++ b/client-go/clientset/versioned/fake/register.go @@ -24,6 +24,7 @@ import ( serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) var scheme = runtime.NewScheme() @@ -31,6 +32,7 @@ var codecs = serializer.NewCodecFactory(scheme) var localSchemeBuilder = runtime.SchemeBuilder{ inferencev1alpha1.AddToScheme, + inferencev1alpha2.AddToScheme, } // AddToScheme adds all types of this clientset into the given scheme. This allows composition diff --git a/client-go/clientset/versioned/scheme/register.go b/client-go/clientset/versioned/scheme/register.go index c4c06158..5727d404 100644 --- a/client-go/clientset/versioned/scheme/register.go +++ b/client-go/clientset/versioned/scheme/register.go @@ -24,6 +24,7 @@ import ( serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) var Scheme = runtime.NewScheme() @@ -31,6 +32,7 @@ var Codecs = serializer.NewCodecFactory(Scheme) var ParameterCodec = runtime.NewParameterCodec(Scheme) var localSchemeBuilder = runtime.SchemeBuilder{ inferencev1alpha1.AddToScheme, + inferencev1alpha2.AddToScheme, } // AddToScheme adds all types of this clientset into the given scheme. This allows composition diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go b/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go new file mode 100644 index 00000000..b011ca92 --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go @@ -0,0 +1,111 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + http "net/http" + + rest "k8s.io/client-go/rest" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" +) + +type InferenceV1alpha2Interface interface { + RESTClient() rest.Interface + InferenceModelsGetter + InferencePoolsGetter +} + +// InferenceV1alpha2Client is used to interact with features provided by the inference.networking.x-k8s.io group. +type InferenceV1alpha2Client struct { + restClient rest.Interface +} + +func (c *InferenceV1alpha2Client) InferenceModels(namespace string) InferenceModelInterface { + return newInferenceModels(c, namespace) +} + +func (c *InferenceV1alpha2Client) InferencePools(namespace string) InferencePoolInterface { + return newInferencePools(c, namespace) +} + +// NewForConfig creates a new InferenceV1alpha2Client for the given config. +// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), +// where httpClient was generated with rest.HTTPClientFor(c). +func NewForConfig(c *rest.Config) (*InferenceV1alpha2Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + httpClient, err := rest.HTTPClientFor(&config) + if err != nil { + return nil, err + } + return NewForConfigAndClient(&config, httpClient) +} + +// NewForConfigAndClient creates a new InferenceV1alpha2Client for the given config and http client. +// Note the http client provided takes precedence over the configured transport values. +func NewForConfigAndClient(c *rest.Config, h *http.Client) (*InferenceV1alpha2Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientForConfigAndClient(&config, h) + if err != nil { + return nil, err + } + return &InferenceV1alpha2Client{client}, nil +} + +// NewForConfigOrDie creates a new InferenceV1alpha2Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *InferenceV1alpha2Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new InferenceV1alpha2Client for the given RESTClient. +func New(c rest.Interface) *InferenceV1alpha2Client { + return &InferenceV1alpha2Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := apiv1alpha2.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(scheme.Scheme, scheme.Codecs).WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *InferenceV1alpha2Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/doc.go b/client-go/clientset/versioned/typed/api/v1alpha2/doc.go new file mode 100644 index 00000000..2bcba220 --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1alpha2 diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go new file mode 100644 index 00000000..fbfccbb9 --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go new file mode 100644 index 00000000..0296608c --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" + v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" +) + +type FakeInferenceV1alpha2 struct { + *testing.Fake +} + +func (c *FakeInferenceV1alpha2) InferenceModels(namespace string) v1alpha2.InferenceModelInterface { + return newFakeInferenceModels(c, namespace) +} + +func (c *FakeInferenceV1alpha2) InferencePools(namespace string) v1alpha2.InferencePoolInterface { + return newFakeInferencePools(c, namespace) +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeInferenceV1alpha2) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go new file mode 100644 index 00000000..2492a557 --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go @@ -0,0 +1,52 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + gentype "k8s.io/client-go/gentype" + v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha2" + typedapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" +) + +// fakeInferenceModels implements InferenceModelInterface +type fakeInferenceModels struct { + *gentype.FakeClientWithListAndApply[*v1alpha2.InferenceModel, *v1alpha2.InferenceModelList, *apiv1alpha2.InferenceModelApplyConfiguration] + Fake *FakeInferenceV1alpha2 +} + +func newFakeInferenceModels(fake *FakeInferenceV1alpha2, namespace string) typedapiv1alpha2.InferenceModelInterface { + return &fakeInferenceModels{ + gentype.NewFakeClientWithListAndApply[*v1alpha2.InferenceModel, *v1alpha2.InferenceModelList, *apiv1alpha2.InferenceModelApplyConfiguration]( + fake.Fake, + namespace, + v1alpha2.SchemeGroupVersion.WithResource("inferencemodels"), + v1alpha2.SchemeGroupVersion.WithKind("InferenceModel"), + func() *v1alpha2.InferenceModel { return &v1alpha2.InferenceModel{} }, + func() *v1alpha2.InferenceModelList { return &v1alpha2.InferenceModelList{} }, + func(dst, src *v1alpha2.InferenceModelList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha2.InferenceModelList) []*v1alpha2.InferenceModel { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha2.InferenceModelList, items []*v1alpha2.InferenceModel) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go new file mode 100644 index 00000000..64b087dd --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go @@ -0,0 +1,52 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + gentype "k8s.io/client-go/gentype" + v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha2" + typedapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" +) + +// fakeInferencePools implements InferencePoolInterface +type fakeInferencePools struct { + *gentype.FakeClientWithListAndApply[*v1alpha2.InferencePool, *v1alpha2.InferencePoolList, *apiv1alpha2.InferencePoolApplyConfiguration] + Fake *FakeInferenceV1alpha2 +} + +func newFakeInferencePools(fake *FakeInferenceV1alpha2, namespace string) typedapiv1alpha2.InferencePoolInterface { + return &fakeInferencePools{ + gentype.NewFakeClientWithListAndApply[*v1alpha2.InferencePool, *v1alpha2.InferencePoolList, *apiv1alpha2.InferencePoolApplyConfiguration]( + fake.Fake, + namespace, + v1alpha2.SchemeGroupVersion.WithResource("inferencepools"), + v1alpha2.SchemeGroupVersion.WithKind("InferencePool"), + func() *v1alpha2.InferencePool { return &v1alpha2.InferencePool{} }, + func() *v1alpha2.InferencePoolList { return &v1alpha2.InferencePoolList{} }, + func(dst, src *v1alpha2.InferencePoolList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha2.InferencePoolList) []*v1alpha2.InferencePool { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha2.InferencePoolList, items []*v1alpha2.InferencePool) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go b/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go new file mode 100644 index 00000000..399789d8 --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go @@ -0,0 +1,22 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +type InferenceModelExpansion interface{} + +type InferencePoolExpansion interface{} diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go new file mode 100644 index 00000000..ee0d92c1 --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go @@ -0,0 +1,73 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + applyconfigurationapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha2" + scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" +) + +// InferenceModelsGetter has a method to return a InferenceModelInterface. +// A group's client should implement this interface. +type InferenceModelsGetter interface { + InferenceModels(namespace string) InferenceModelInterface +} + +// InferenceModelInterface has methods to work with InferenceModel resources. +type InferenceModelInterface interface { + Create(ctx context.Context, inferenceModel *apiv1alpha2.InferenceModel, opts v1.CreateOptions) (*apiv1alpha2.InferenceModel, error) + Update(ctx context.Context, inferenceModel *apiv1alpha2.InferenceModel, opts v1.UpdateOptions) (*apiv1alpha2.InferenceModel, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, inferenceModel *apiv1alpha2.InferenceModel, opts v1.UpdateOptions) (*apiv1alpha2.InferenceModel, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha2.InferenceModel, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha2.InferenceModelList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha2.InferenceModel, err error) + Apply(ctx context.Context, inferenceModel *applyconfigurationapiv1alpha2.InferenceModelApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha2.InferenceModel, err error) + // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). + ApplyStatus(ctx context.Context, inferenceModel *applyconfigurationapiv1alpha2.InferenceModelApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha2.InferenceModel, err error) + InferenceModelExpansion +} + +// inferenceModels implements InferenceModelInterface +type inferenceModels struct { + *gentype.ClientWithListAndApply[*apiv1alpha2.InferenceModel, *apiv1alpha2.InferenceModelList, *applyconfigurationapiv1alpha2.InferenceModelApplyConfiguration] +} + +// newInferenceModels returns a InferenceModels +func newInferenceModels(c *InferenceV1alpha2Client, namespace string) *inferenceModels { + return &inferenceModels{ + gentype.NewClientWithListAndApply[*apiv1alpha2.InferenceModel, *apiv1alpha2.InferenceModelList, *applyconfigurationapiv1alpha2.InferenceModelApplyConfiguration]( + "inferencemodels", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha2.InferenceModel { return &apiv1alpha2.InferenceModel{} }, + func() *apiv1alpha2.InferenceModelList { return &apiv1alpha2.InferenceModelList{} }, + ), + } +} diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go new file mode 100644 index 00000000..8482451e --- /dev/null +++ b/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go @@ -0,0 +1,73 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + applyconfigurationapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha2" + scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" +) + +// InferencePoolsGetter has a method to return a InferencePoolInterface. +// A group's client should implement this interface. +type InferencePoolsGetter interface { + InferencePools(namespace string) InferencePoolInterface +} + +// InferencePoolInterface has methods to work with InferencePool resources. +type InferencePoolInterface interface { + Create(ctx context.Context, inferencePool *apiv1alpha2.InferencePool, opts v1.CreateOptions) (*apiv1alpha2.InferencePool, error) + Update(ctx context.Context, inferencePool *apiv1alpha2.InferencePool, opts v1.UpdateOptions) (*apiv1alpha2.InferencePool, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, inferencePool *apiv1alpha2.InferencePool, opts v1.UpdateOptions) (*apiv1alpha2.InferencePool, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha2.InferencePool, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha2.InferencePoolList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha2.InferencePool, err error) + Apply(ctx context.Context, inferencePool *applyconfigurationapiv1alpha2.InferencePoolApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha2.InferencePool, err error) + // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). + ApplyStatus(ctx context.Context, inferencePool *applyconfigurationapiv1alpha2.InferencePoolApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha2.InferencePool, err error) + InferencePoolExpansion +} + +// inferencePools implements InferencePoolInterface +type inferencePools struct { + *gentype.ClientWithListAndApply[*apiv1alpha2.InferencePool, *apiv1alpha2.InferencePoolList, *applyconfigurationapiv1alpha2.InferencePoolApplyConfiguration] +} + +// newInferencePools returns a InferencePools +func newInferencePools(c *InferenceV1alpha2Client, namespace string) *inferencePools { + return &inferencePools{ + gentype.NewClientWithListAndApply[*apiv1alpha2.InferencePool, *apiv1alpha2.InferencePoolList, *applyconfigurationapiv1alpha2.InferencePoolApplyConfiguration]( + "inferencepools", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha2.InferencePool { return &apiv1alpha2.InferencePool{} }, + func() *apiv1alpha2.InferencePoolList { return &apiv1alpha2.InferencePoolList{} }, + ), + } +} diff --git a/client-go/informers/externalversions/api/interface.go b/client-go/informers/externalversions/api/interface.go index fbf5ba09..210b89f8 100644 --- a/client-go/informers/externalversions/api/interface.go +++ b/client-go/informers/externalversions/api/interface.go @@ -19,6 +19,7 @@ package api import ( v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api/v1alpha1" + v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api/v1alpha2" internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" ) @@ -26,6 +27,8 @@ import ( type Interface interface { // V1alpha1 provides access to shared informers for resources in V1alpha1. V1alpha1() v1alpha1.Interface + // V1alpha2 provides access to shared informers for resources in V1alpha2. + V1alpha2() v1alpha2.Interface } type group struct { @@ -43,3 +46,8 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList func (g *group) V1alpha1() v1alpha1.Interface { return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) } + +// V1alpha2 returns a new v1alpha2.Interface. +func (g *group) V1alpha2() v1alpha2.Interface { + return v1alpha2.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go b/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go new file mode 100644 index 00000000..74f640d1 --- /dev/null +++ b/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + time "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" + gatewayapiinferenceextensionapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha2" +) + +// InferenceModelInformer provides access to a shared informer and lister for +// InferenceModels. +type InferenceModelInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha2.InferenceModelLister +} + +type inferenceModelInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewInferenceModelInformer constructs a new informer for InferenceModel type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewInferenceModelInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredInferenceModelInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredInferenceModelInformer constructs a new informer for InferenceModel type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredInferenceModelInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.InferenceV1alpha2().InferenceModels(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.InferenceV1alpha2().InferenceModels(namespace).Watch(context.TODO(), options) + }, + }, + &gatewayapiinferenceextensionapiv1alpha2.InferenceModel{}, + resyncPeriod, + indexers, + ) +} + +func (f *inferenceModelInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredInferenceModelInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *inferenceModelInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&gatewayapiinferenceextensionapiv1alpha2.InferenceModel{}, f.defaultInformer) +} + +func (f *inferenceModelInformer) Lister() apiv1alpha2.InferenceModelLister { + return apiv1alpha2.NewInferenceModelLister(f.Informer().GetIndexer()) +} diff --git a/client-go/informers/externalversions/api/v1alpha2/inferencepool.go b/client-go/informers/externalversions/api/v1alpha2/inferencepool.go new file mode 100644 index 00000000..d04591dd --- /dev/null +++ b/client-go/informers/externalversions/api/v1alpha2/inferencepool.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + time "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" + gatewayapiinferenceextensionapiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha2" +) + +// InferencePoolInformer provides access to a shared informer and lister for +// InferencePools. +type InferencePoolInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha2.InferencePoolLister +} + +type inferencePoolInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewInferencePoolInformer constructs a new informer for InferencePool type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewInferencePoolInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredInferencePoolInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredInferencePoolInformer constructs a new informer for InferencePool type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredInferencePoolInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.InferenceV1alpha2().InferencePools(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.InferenceV1alpha2().InferencePools(namespace).Watch(context.TODO(), options) + }, + }, + &gatewayapiinferenceextensionapiv1alpha2.InferencePool{}, + resyncPeriod, + indexers, + ) +} + +func (f *inferencePoolInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredInferencePoolInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *inferencePoolInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&gatewayapiinferenceextensionapiv1alpha2.InferencePool{}, f.defaultInformer) +} + +func (f *inferencePoolInformer) Lister() apiv1alpha2.InferencePoolLister { + return apiv1alpha2.NewInferencePoolLister(f.Informer().GetIndexer()) +} diff --git a/client-go/informers/externalversions/api/v1alpha2/interface.go b/client-go/informers/externalversions/api/v1alpha2/interface.go new file mode 100644 index 00000000..9e5c4d9c --- /dev/null +++ b/client-go/informers/externalversions/api/v1alpha2/interface.go @@ -0,0 +1,51 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // InferenceModels returns a InferenceModelInformer. + InferenceModels() InferenceModelInformer + // InferencePools returns a InferencePoolInformer. + InferencePools() InferencePoolInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// InferenceModels returns a InferenceModelInformer. +func (v *version) InferenceModels() InferenceModelInformer { + return &inferenceModelInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + +// InferencePools returns a InferencePoolInformer. +func (v *version) InferencePools() InferencePoolInformer { + return &inferencePoolInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/client-go/informers/externalversions/generic.go b/client-go/informers/externalversions/generic.go index 672998f5..9f363d88 100644 --- a/client-go/informers/externalversions/generic.go +++ b/client-go/informers/externalversions/generic.go @@ -23,6 +23,7 @@ import ( schema "k8s.io/apimachinery/pkg/runtime/schema" cache "k8s.io/client-go/tools/cache" v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) // GenericInformer is type of SharedIndexInformer which will locate and delegate to other @@ -57,6 +58,12 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource case v1alpha1.SchemeGroupVersion.WithResource("inferencepools"): return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha1().InferencePools().Informer()}, nil + // Group=inference.networking.x-k8s.io, Version=v1alpha2 + case v1alpha2.SchemeGroupVersion.WithResource("inferencemodels"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha2().InferenceModels().Informer()}, nil + case v1alpha2.SchemeGroupVersion.WithResource("inferencepools"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha2().InferencePools().Informer()}, nil + } return nil, fmt.Errorf("no informer found for %v", resource) diff --git a/client-go/listers/api/v1alpha2/expansion_generated.go b/client-go/listers/api/v1alpha2/expansion_generated.go new file mode 100644 index 00000000..204c375b --- /dev/null +++ b/client-go/listers/api/v1alpha2/expansion_generated.go @@ -0,0 +1,34 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +// InferenceModelListerExpansion allows custom methods to be added to +// InferenceModelLister. +type InferenceModelListerExpansion interface{} + +// InferenceModelNamespaceListerExpansion allows custom methods to be added to +// InferenceModelNamespaceLister. +type InferenceModelNamespaceListerExpansion interface{} + +// InferencePoolListerExpansion allows custom methods to be added to +// InferencePoolLister. +type InferencePoolListerExpansion interface{} + +// InferencePoolNamespaceListerExpansion allows custom methods to be added to +// InferencePoolNamespaceLister. +type InferencePoolNamespaceListerExpansion interface{} diff --git a/client-go/listers/api/v1alpha2/inferencemodel.go b/client-go/listers/api/v1alpha2/inferencemodel.go new file mode 100644 index 00000000..ce83b85f --- /dev/null +++ b/client-go/listers/api/v1alpha2/inferencemodel.go @@ -0,0 +1,69 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// InferenceModelLister helps list InferenceModels. +// All objects returned here must be treated as read-only. +type InferenceModelLister interface { + // List lists all InferenceModels in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha2.InferenceModel, err error) + // InferenceModels returns an object that can list and get InferenceModels. + InferenceModels(namespace string) InferenceModelNamespaceLister + InferenceModelListerExpansion +} + +// inferenceModelLister implements the InferenceModelLister interface. +type inferenceModelLister struct { + listers.ResourceIndexer[*apiv1alpha2.InferenceModel] +} + +// NewInferenceModelLister returns a new InferenceModelLister. +func NewInferenceModelLister(indexer cache.Indexer) InferenceModelLister { + return &inferenceModelLister{listers.New[*apiv1alpha2.InferenceModel](indexer, apiv1alpha2.Resource("inferencemodel"))} +} + +// InferenceModels returns an object that can list and get InferenceModels. +func (s *inferenceModelLister) InferenceModels(namespace string) InferenceModelNamespaceLister { + return inferenceModelNamespaceLister{listers.NewNamespaced[*apiv1alpha2.InferenceModel](s.ResourceIndexer, namespace)} +} + +// InferenceModelNamespaceLister helps list and get InferenceModels. +// All objects returned here must be treated as read-only. +type InferenceModelNamespaceLister interface { + // List lists all InferenceModels in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha2.InferenceModel, err error) + // Get retrieves the InferenceModel from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha2.InferenceModel, error) + InferenceModelNamespaceListerExpansion +} + +// inferenceModelNamespaceLister implements the InferenceModelNamespaceLister +// interface. +type inferenceModelNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha2.InferenceModel] +} diff --git a/client-go/listers/api/v1alpha2/inferencepool.go b/client-go/listers/api/v1alpha2/inferencepool.go new file mode 100644 index 00000000..c7e49a1e --- /dev/null +++ b/client-go/listers/api/v1alpha2/inferencepool.go @@ -0,0 +1,69 @@ +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// InferencePoolLister helps list InferencePools. +// All objects returned here must be treated as read-only. +type InferencePoolLister interface { + // List lists all InferencePools in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha2.InferencePool, err error) + // InferencePools returns an object that can list and get InferencePools. + InferencePools(namespace string) InferencePoolNamespaceLister + InferencePoolListerExpansion +} + +// inferencePoolLister implements the InferencePoolLister interface. +type inferencePoolLister struct { + listers.ResourceIndexer[*apiv1alpha2.InferencePool] +} + +// NewInferencePoolLister returns a new InferencePoolLister. +func NewInferencePoolLister(indexer cache.Indexer) InferencePoolLister { + return &inferencePoolLister{listers.New[*apiv1alpha2.InferencePool](indexer, apiv1alpha2.Resource("inferencepool"))} +} + +// InferencePools returns an object that can list and get InferencePools. +func (s *inferencePoolLister) InferencePools(namespace string) InferencePoolNamespaceLister { + return inferencePoolNamespaceLister{listers.NewNamespaced[*apiv1alpha2.InferencePool](s.ResourceIndexer, namespace)} +} + +// InferencePoolNamespaceLister helps list and get InferencePools. +// All objects returned here must be treated as read-only. +type InferencePoolNamespaceLister interface { + // List lists all InferencePools in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha2.InferencePool, err error) + // Get retrieves the InferencePool from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha2.InferencePool, error) + InferencePoolNamespaceListerExpansion +} + +// inferencePoolNamespaceLister implements the InferencePoolNamespaceLister +// interface. +type inferencePoolNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha2.InferencePool] +} diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 1f76cfab..dd47fa27 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -40,6 +40,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/vllm" @@ -104,6 +105,8 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.AddToScheme(scheme)) + } func main() { diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml index bca19605..09258c20 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml @@ -235,6 +235,230 @@ spec: type: object type: object served: true + storage: false + subresources: + status: {} + - name: v1alpha2 + schema: + openAPIV3Schema: + description: InferenceModel is the Schema for the InferenceModels API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + InferenceModelSpec represents the desired state of a specific model use case. This resource is + managed by the "Inference Workload Owner" persona. + + The Inference Workload Owner persona is someone that trains, verifies, and + leverages a large language model from a model frontend, drives the lifecycle + and rollout of new versions of those models, and defines the specific + performance and latency goals for the model. These workloads are + expected to operate within an InferencePool sharing compute capacity with other + InferenceModels, defined by the Inference Platform Admin. + + InferenceModel's modelName (not the ObjectMeta name) is unique for a given InferencePool, + if the name is reused, an error will be shown on the status of a + InferenceModel that attempted to reuse. The oldest InferenceModel, based on + creation timestamp, will be selected to remain valid. In the event of a race + condition, one will be selected at random. + properties: + criticality: + description: |- + Criticality defines how important it is to serve the model compared to other models referencing the same pool. + Criticality impacts how traffic is handled in resource constrained situations. It handles this by + queuing or rejecting requests of lower criticality. InferenceModels of an equivalent Criticality will + fairly share resources over throughput of tokens. In the future, the metric used to calculate fairness, + and the proportionality of fairness will be configurable. + + Default values for this field will not be set, to allow for future additions of new field that may 'one of' with this field. + Any implementations that may consume this field may treat an unset value as the 'Standard' range. + enum: + - Critical + - Standard + - Sheddable + type: string + modelName: + description: |- + ModelName is the name of the model as it will be set in the "model" parameter for an incoming request. + ModelNames must be unique for a referencing InferencePool + (names can be reused for a different pool in the same cluster). + The modelName with the oldest creation timestamp is retained, and the incoming + InferenceModel is sets the Ready status to false with a corresponding reason. + In the rare case of a race condition, one Model will be selected randomly to be considered valid, and the other rejected. + Names can be reserved without an underlying model configured in the pool. + This can be done by specifying a target model and setting the weight to zero, + an error will be returned specifying that no valid target model is found. + maxLength: 256 + type: string + poolRef: + description: PoolRef is a reference to the inference pool, the pool + must exist in the same namespace. + properties: + group: + default: inference.networking.x-k8s.io + description: Group is the group of the referent. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + kind: + default: InferencePool + description: Kind is kind of the referent. For example "InferencePool". + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: Name is the name of the referent. + maxLength: 253 + minLength: 1 + type: string + required: + - name + type: object + targetModels: + description: |- + TargetModels allow multiple versions of a model for traffic splitting. + If not specified, the target model name is defaulted to the modelName parameter. + modelName is often in reference to a LoRA adapter. + items: + description: |- + TargetModel represents a deployed model or a LoRA adapter. The + Name field is expected to match the name of the LoRA adapter + (or base model) as it is registered within the model server. Inference + Gateway assumes that the model exists on the model server and it's the + responsibility of the user to validate a correct match. Should a model fail + to exist at request time, the error is processed by the Inference Gateway + and emitted on the appropriate InferenceModel object. + properties: + name: + description: Name is the name of the adapter or base model, + as expected by the ModelServer. + maxLength: 253 + type: string + weight: + description: |- + Weight is used to determine the proportion of traffic that should be + sent to this model when multiple target models are specified. + + Weight defines the proportion of requests forwarded to the specified + model. This is computed as weight/(sum of all weights in this + TargetModels list). For non-zero values, there may be some epsilon from + the exact proportion defined here depending on the precision an + implementation supports. Weight is not a percentage and the sum of + weights does not need to equal 100. + + If a weight is set for any targetModel, it must be set for all targetModels. + Conversely weights are optional, so long as ALL targetModels do not specify a weight. + format: int32 + maximum: 1000000 + minimum: 0 + type: integer + required: + - name + type: object + maxItems: 10 + type: array + x-kubernetes-validations: + - message: Weights should be set for all models, or none of the models. + rule: self.all(model, has(model.weight)) || self.all(model, !has(model.weight)) + required: + - modelName + - poolRef + type: object + status: + description: InferenceModelStatus defines the observed state of InferenceModel + properties: + conditions: + default: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for controller + reason: Pending + status: Unknown + type: Ready + description: |- + Conditions track the state of the InferenceModel. + + Known condition types are: + + * "Accepted" + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true storage: true subresources: status: {} diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml index 9e6473b9..918e95cb 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml @@ -201,6 +201,258 @@ spec: type: object type: object served: true + storage: false + subresources: + status: {} + - name: v1alpha2 + schema: + openAPIV3Schema: + description: InferencePool is the Schema for the InferencePools API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: InferencePoolSpec defines the desired state of InferencePool + properties: + extensionRef: + description: Extension configures an endpoint picker as an extension + service. + properties: + failureMode: + default: FailClose + description: |- + Configures how the gateway handles the case when the extension is not responsive. + Defaults to failClose. + enum: + - FailOpen + - FailClose + type: string + group: + default: "" + description: |- + Group is the group of the referent. + When unspecified or empty string, core API group is inferred. + type: string + kind: + default: Service + description: |- + Kind is the Kubernetes resource kind of the referent. For example + "Service". + + Defaults to "Service" when not specified. + + ExternalName services can refer to CNAME DNS records that may live + outside of the cluster and as such are difficult to reason about in + terms of conformance. They also may not be safe to forward to (see + CVE-2021-25740 for more information). Implementations MUST NOT + support ExternalName Services. + type: string + name: + description: Name is the name of the referent. + type: string + targetPortNumber: + description: |- + The port number on the service running the extension. When unspecified, implementations SHOULD infer a + default value of 9002 when the Kind is Service. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - name + type: object + selector: + additionalProperties: + description: |- + LabelValue is the value of a label. This is used for validation + of maps. This matches the Kubernetes label validation rules: + * must be 63 characters or less (can be empty), + * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), + * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. + + Valid values include: + + * MyValue + * my.name + * 123-my-value + maxLength: 63 + minLength: 0 + pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ + type: string + description: |- + Selector defines a map of labels to watch model server pods + that should be included in the InferencePool. + In some cases, implementations may translate this field to a Service selector, so this matches the simple + map used for Service selectors instead of the full Kubernetes LabelSelector type. + type: object + targetPortNumber: + description: |- + TargetPortNumber defines the port number to access the selected model servers. + The number must be in the range 1 to 65535. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - extensionRef + - selector + - targetPortNumber + type: object + status: + description: InferencePoolStatus defines the observed state of InferencePool + properties: + parent: + description: |- + Parents is a list of parent resources (usually Gateways) that are + associated with the route, and the status of the InferencePool with respect to + each parent. + + A maximum of 32 Gateways will be represented in this list. An empty list + means the route has not been attached to any Gateway. + items: + description: PoolStatus defines the observed state of InferencePool + from a gateway. + properties: + conditions: + default: + - lastTransitionTime: "1970-01-01T00:00:00Z" + message: Waiting for controller + reason: Pending + status: Unknown + type: Ready + description: |- + Conditions track the state of the InferencePool. + + Known condition types are: + + * "Ready" + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + parentRef: + description: GatewayRef indicates the gateway that observed + state of InferencePool. + properties: + apiVersion: + description: API version of the referent. + type: string + fieldPath: + description: |- + If referring to a piece of an object instead of an entire object, this string + should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2]. + For example, if the object reference is to a container within a pod, this would take on a value like: + "spec.containers{name}" (where "name" refers to the name of the container that triggered + the event) or if no container name is specified "spec.containers[2]" (container with + index 2 in this pod). This syntax is chosen only to have some well-defined way of + referencing a part of an object. + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + namespace: + description: |- + Namespace of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + type: string + resourceVersion: + description: |- + Specific resourceVersion to which this reference is made, if any. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency + type: string + uid: + description: |- + UID of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids + type: string + type: object + x-kubernetes-map-type: atomic + required: + - parentRef + type: object + maxItems: 32 + type: array + type: object + type: object + served: true storage: true subresources: status: {} diff --git a/pkg/epp/backend/fake.go b/pkg/epp/backend/fake.go index e81b3817..06f14f69 100644 --- a/pkg/epp/backend/fake.go +++ b/pkg/epp/backend/fake.go @@ -21,7 +21,7 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -40,9 +40,9 @@ func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *datas } type FakeDataStore struct { - Res map[string]*v1alpha1.InferenceModel + Res map[string]*v1alpha2.InferenceModel } -func (fds *FakeDataStore) FetchModelData(modelName string) (returnModel *v1alpha1.InferenceModel) { +func (fds *FakeDataStore) FetchModelData(modelName string) (returnModel *v1alpha2.InferenceModel) { return fds.Res[modelName] } diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index 99a1eb26..00358740 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -27,7 +27,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -49,7 +49,7 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque loggerDefault := logger.V(logutil.DEFAULT) loggerDefault.Info("Reconciling InferenceModel", "name", req.NamespacedName) - infModel := &v1alpha1.InferenceModel{} + infModel := &v1alpha2.InferenceModel{} if err := c.Get(ctx, req.NamespacedName, infModel); err != nil { if errors.IsNotFound(err) { loggerDefault.Info("InferenceModel not found. Removing from datastore since object must be deleted", "name", req.NamespacedName) @@ -68,7 +68,7 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque return ctrl.Result{}, nil } -func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel *v1alpha1.InferenceModel) { +func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel *v1alpha2.InferenceModel) { loggerDefault := logger.V(logutil.DEFAULT) if infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name { @@ -84,6 +84,6 @@ func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel func (c *InferenceModelReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.InferenceModel{}). + For(&v1alpha2.InferenceModel{}). Complete(c) } diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index cf94b168..cea7bf42 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -28,34 +28,34 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) var ( - infModel1 = &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ + infModel1 = &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "fake model1", - PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, + PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-service", }, } - infModel1Modified = &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ + infModel1Modified = &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "fake model1", - PoolRef: v1alpha1.PoolObjectReference{Name: "test-poolio"}, + PoolRef: v1alpha2.PoolObjectReference{Name: "test-poolio"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-service", }, } - infModel2 = &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ + infModel2 = &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "fake model", - PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, + PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-service-2", @@ -69,14 +69,14 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { tests := []struct { name string datastore datastore.Datastore - incomingService *v1alpha1.InferenceModel + incomingService *v1alpha2.InferenceModel wantInferenceModels *sync.Map }{ { name: "No Services registered; valid, new service incoming.", - datastore: datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, + datastore: datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-pool", @@ -89,9 +89,9 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { }, { name: "Removing existing service.", - datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, + datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-pool", @@ -103,19 +103,19 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { }, { name: "Unrelated service, do nothing.", - datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, + datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-pool", ResourceVersion: "Old and boring", }, }), - incomingService: &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ + incomingService: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "fake model", - PoolRef: v1alpha1.PoolObjectReference{Name: "test-poolio"}, + PoolRef: v1alpha2.PoolObjectReference{Name: "test-poolio"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "unrelated-service", @@ -125,9 +125,9 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { }, { name: "Add to existing", - datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm"}, + datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, }, ObjectMeta: metav1.ObjectMeta{ Name: "test-pool", @@ -164,13 +164,13 @@ func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { func TestReconcile_ResourceNotFound(t *testing.T) { // Set up the scheme. scheme := runtime.NewScheme() - _ = v1alpha1.AddToScheme(scheme) + _ = v1alpha2.AddToScheme(scheme) // Create a fake client with no InferenceModel objects. fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() // Create a minimal datastore. - datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, }) @@ -201,20 +201,20 @@ func TestReconcile_ResourceNotFound(t *testing.T) { func TestReconcile_ModelMarkedForDeletion(t *testing.T) { // Set up the scheme. scheme := runtime.NewScheme() - _ = v1alpha1.AddToScheme(scheme) + _ = v1alpha2.AddToScheme(scheme) // Create an InferenceModel object. now := metav1.Now() - existingModel := &v1alpha1.InferenceModel{ + existingModel := &v1alpha2.InferenceModel{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-model", Namespace: "default", DeletionTimestamp: &now, Finalizers: []string{"finalizer"}, }, - Spec: v1alpha1.InferenceModelSpec{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "fake-model", - PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, + PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, }, } @@ -222,7 +222,7 @@ func TestReconcile_ModelMarkedForDeletion(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() // Create a minimal datastore. - datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, }) @@ -258,17 +258,17 @@ func TestReconcile_ModelMarkedForDeletion(t *testing.T) { func TestReconcile_ResourceExists(t *testing.T) { // Set up the scheme. scheme := runtime.NewScheme() - _ = v1alpha1.AddToScheme(scheme) + _ = v1alpha2.AddToScheme(scheme) // Create an InferenceModel object. - existingModel := &v1alpha1.InferenceModel{ + existingModel := &v1alpha2.InferenceModel{ ObjectMeta: metav1.ObjectMeta{ Name: "existing-model", Namespace: "default", }, - Spec: v1alpha1.InferenceModelSpec{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "fake-model", - PoolRef: v1alpha1.PoolObjectReference{Name: "test-pool"}, + PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, }, } @@ -276,7 +276,7 @@ func TestReconcile_ResourceExists(t *testing.T) { fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() // Create a minimal datastore. - datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha1.InferencePool{ + datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, }) @@ -309,7 +309,7 @@ func TestReconcile_ResourceExists(t *testing.T) { } } -func populateServiceMap(services ...*v1alpha1.InferenceModel) *sync.Map { +func populateServiceMap(services ...*v1alpha2.InferenceModel) *sync.Map { returnVal := &sync.Map{} for _, service := range services { diff --git a/pkg/epp/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go index f2c56991..baf3332b 100644 --- a/pkg/epp/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -27,7 +27,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -52,7 +52,7 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques loggerDefault := logger.V(logutil.DEFAULT) loggerDefault.Info("Reconciling InferencePool", "name", req.NamespacedName) - serverPool := &v1alpha1.InferencePool{} + serverPool := &v1alpha2.InferencePool{} if err := c.Get(ctx, req.NamespacedName, serverPool); err != nil { if errors.IsNotFound(err) { @@ -73,7 +73,7 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, nil } -func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool *v1alpha1.InferencePool) { +func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool *v1alpha2.InferencePool) { logger := log.FromContext(ctx) oldPool, err := c.Datastore.PoolGet() c.Datastore.PoolSet(newPool) @@ -91,6 +91,6 @@ func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool * func (c *InferencePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.InferencePool{}). + For(&v1alpha2.InferencePool{}). Complete(c) } diff --git a/pkg/epp/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go index 6263fa16..a96406f0 100644 --- a/pkg/epp/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -30,7 +30,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) @@ -38,17 +38,17 @@ import ( var ( selector_v1 = map[string]string{"app": "vllm_v1"} selector_v2 = map[string]string{"app": "vllm_v2"} - pool1 = &v1alpha1.InferencePool{ + pool1 = &v1alpha2.InferencePool{ ObjectMeta: metav1.ObjectMeta{ Name: "pool1", Namespace: "pool1-ns", }, - Spec: v1alpha1.InferencePoolSpec{ - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v1"}, + Spec: v1alpha2.InferencePoolSpec{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm_v1"}, TargetPortNumber: 8080, }, } - pool2 = &v1alpha1.InferencePool{ + pool2 = &v1alpha2.InferencePool{ ObjectMeta: metav1.ObjectMeta{ Name: "pool2", Namespace: "pool2-ns", @@ -74,7 +74,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { // Set up the scheme. scheme := runtime.NewScheme() _ = clientgoscheme.AddToScheme(scheme) - _ = v1alpha1.AddToScheme(scheme) + _ = v1alpha2.AddToScheme(scheme) // Create a fake client with the pool and the pods. initialObjects := []client.Object{pool1, pool2} @@ -111,11 +111,11 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { } // Step 3: update the pool selector to include more pods - newPool1 := &v1alpha1.InferencePool{} + newPool1 := &v1alpha2.InferencePool{} if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { t.Errorf("Unexpected pool get error: %v", err) } - newPool1.Spec.Selector = map[v1alpha1.LabelKey]v1alpha1.LabelValue{"app": "vllm_v2"} + newPool1.Spec.Selector = map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm_v2"} if err := fakeClient.Update(ctx, newPool1, &client.UpdateOptions{}); err != nil { t.Errorf("Unexpected pool update error: %v", err) } @@ -157,7 +157,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { } } -func diffPool(datastore datastore.Datastore, wantPool *v1alpha1.InferencePool, wantPods []string) string { +func diffPool(datastore datastore.Datastore, wantPool *v1alpha2.InferencePool, wantPods []string) string { gotPool, _ := datastore.PoolGet() if diff := cmp.Diff(wantPool, gotPool); diff != "" { return diff diff --git a/pkg/epp/controller/pod_reconciler_test.go b/pkg/epp/controller/pod_reconciler_test.go index b3869113..8a39dbab 100644 --- a/pkg/epp/controller/pod_reconciler_test.go +++ b/pkg/epp/controller/pod_reconciler_test.go @@ -31,7 +31,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" ) @@ -53,10 +53,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }{ { name: "Add new pod", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, @@ -82,10 +82,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "Update pod1 address", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, @@ -111,10 +111,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "Delete pod with DeletionTimestamp", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, @@ -141,10 +141,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "Delete notfound pod", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, @@ -154,10 +154,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "New pod, not ready, valid selector", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, @@ -182,10 +182,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "Remove pod that does not match selector", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, @@ -210,10 +210,10 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "Remove pod that is not ready", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha1.InferencePool{ - Spec: v1alpha1.InferencePoolSpec{ + datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), - Selector: map[v1alpha1.LabelKey]v1alpha1.LabelValue{ + Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index eecea59c..c5bbddcf 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -28,21 +28,21 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) type Datastore interface { // InferencePool operations - PoolSet(pool *v1alpha1.InferencePool) - PoolGet() (*v1alpha1.InferencePool, error) + PoolSet(pool *v1alpha2.InferencePool) + PoolGet() (*v1alpha2.InferencePool, error) PoolHasSynced() bool PoolLabelsMatch(podLabels map[string]string) bool // InferenceModel operations - ModelSet(infModel *v1alpha1.InferenceModel) - ModelGet(modelName string) (*v1alpha1.InferenceModel, bool) + ModelSet(infModel *v1alpha2.InferenceModel) + ModelGet(modelName string) (*v1alpha2.InferenceModel, bool) ModelDelete(modelName string) // PodMetrics operations @@ -69,7 +69,7 @@ func NewDatastore() Datastore { } // Used for test only -func NewFakeDatastore(pods, models *sync.Map, pool *v1alpha1.InferencePool) Datastore { +func NewFakeDatastore(pods, models *sync.Map, pool *v1alpha2.InferencePool) Datastore { store := NewDatastore() if pods != nil { store.(*datastore).pods = pods @@ -86,7 +86,7 @@ func NewFakeDatastore(pods, models *sync.Map, pool *v1alpha1.InferencePool) Data type datastore struct { // poolMu is used to synchronize access to the inferencePool. poolMu sync.RWMutex - pool *v1alpha1.InferencePool + pool *v1alpha2.InferencePool models *sync.Map // key: types.NamespacedName, value: *PodMetrics pods *sync.Map @@ -101,13 +101,13 @@ func (ds *datastore) Clear() { } // /// InferencePool APIs /// -func (ds *datastore) PoolSet(pool *v1alpha1.InferencePool) { +func (ds *datastore) PoolSet(pool *v1alpha2.InferencePool) { ds.poolMu.Lock() defer ds.poolMu.Unlock() ds.pool = pool } -func (ds *datastore) PoolGet() (*v1alpha1.InferencePool, error) { +func (ds *datastore) PoolGet() (*v1alpha2.InferencePool, error) { ds.poolMu.RLock() defer ds.poolMu.RUnlock() if !ds.PoolHasSynced() { @@ -129,14 +129,14 @@ func (ds *datastore) PoolLabelsMatch(podLabels map[string]string) bool { } // /// InferenceModel APIs /// -func (ds *datastore) ModelSet(infModel *v1alpha1.InferenceModel) { +func (ds *datastore) ModelSet(infModel *v1alpha2.InferenceModel) { ds.models.Store(infModel.Spec.ModelName, infModel) } -func (ds *datastore) ModelGet(modelName string) (*v1alpha1.InferenceModel, bool) { +func (ds *datastore) ModelGet(modelName string) (*v1alpha2.InferenceModel, bool) { infModel, ok := ds.models.Load(modelName) if ok { - return infModel.(*v1alpha1.InferenceModel), true + return infModel.(*v1alpha2.InferenceModel), true } return nil, false } @@ -243,11 +243,11 @@ func (ds *datastore) PodDeleteAll() { ds.pods.Clear() } -func selectorFromInferencePoolSelector(selector map[v1alpha1.LabelKey]v1alpha1.LabelValue) labels.Selector { +func selectorFromInferencePoolSelector(selector map[v1alpha2.LabelKey]v1alpha2.LabelValue) labels.Selector { return labels.SelectorFromSet(stripLabelKeyAliasFromLabelMap(selector)) } -func stripLabelKeyAliasFromLabelMap(labels map[v1alpha1.LabelKey]v1alpha1.LabelValue) map[string]string { +func stripLabelKeyAliasFromLabelMap(labels map[v1alpha2.LabelKey]v1alpha2.LabelValue) map[string]string { outMap := make(map[string]string) for k, v := range labels { outMap[string(k)] = string(v) @@ -255,7 +255,7 @@ func stripLabelKeyAliasFromLabelMap(labels map[v1alpha1.LabelKey]v1alpha1.LabelV return outMap } -func RandomWeightedDraw(logger logr.Logger, model *v1alpha1.InferenceModel, seed int64) string { +func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed int64) string { var weights int32 source := rand.NewSource(rand.Int63()) @@ -277,8 +277,8 @@ func RandomWeightedDraw(logger logr.Logger, model *v1alpha1.InferenceModel, seed return "" } -func IsCritical(model *v1alpha1.InferenceModel) bool { - if model.Spec.Criticality != nil && *model.Spec.Criticality == v1alpha1.Critical { +func IsCritical(model *v1alpha2.InferenceModel) bool { + if model.Spec.Criticality != nil && *model.Spec.Criticality == v1alpha2.Critical { return true } return false diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index bd5c5020..2af36541 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -20,19 +20,19 @@ import ( "testing" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) func TestHasSynced(t *testing.T) { tests := []struct { name string - inferencePool *v1alpha1.InferencePool + inferencePool *v1alpha2.InferencePool hasSynced bool }{ { name: "Ready when InferencePool exists in data store", - inferencePool: &v1alpha1.InferencePool{ + inferencePool: &v1alpha2.InferencePool{ ObjectMeta: v1.ObjectMeta{ Name: "test-pool", Namespace: "default", @@ -66,14 +66,14 @@ func TestRandomWeightedDraw(t *testing.T) { logger := logutil.NewTestLogger() tests := []struct { name string - model *v1alpha1.InferenceModel + model *v1alpha2.InferenceModel want string }{ { name: "'random' distribution", - model: &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ - TargetModels: []v1alpha1.TargetModel{ + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ { Name: "canary", Weight: pointer(50), @@ -89,9 +89,9 @@ func TestRandomWeightedDraw(t *testing.T) { }, { name: "'random' distribution", - model: &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ - TargetModels: []v1alpha1.TargetModel{ + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ { Name: "canary", Weight: pointer(25), @@ -111,9 +111,9 @@ func TestRandomWeightedDraw(t *testing.T) { }, { name: "'random' distribution", - model: &v1alpha1.InferenceModel{ - Spec: v1alpha1.InferenceModelSpec{ - TargetModels: []v1alpha1.TargetModel{ + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ { Name: "canary", Weight: pointer(20), diff --git a/pkg/epp/test/benchmark/benchmark.go b/pkg/epp/test/benchmark/benchmark.go index 10987b47..67783480 100644 --- a/pkg/epp/test/benchmark/benchmark.go +++ b/pkg/epp/test/benchmark/benchmark.go @@ -31,7 +31,7 @@ import ( "google.golang.org/protobuf/proto" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" @@ -108,12 +108,12 @@ func generateRequestFunc(logger logr.Logger) func(mtd *desc.MethodDescriptor, ca } } -func fakeModels() map[string]*v1alpha1.InferenceModel { - models := map[string]*v1alpha1.InferenceModel{} +func fakeModels() map[string]*v1alpha2.InferenceModel { + models := map[string]*v1alpha2.InferenceModel{} for i := range *numFakePods { for j := range *numModelsPerPod { m := modelName(i*(*numModelsPerPod) + j) - models[m] = &v1alpha1.InferenceModel{Spec: v1alpha1.InferenceModelSpec{ModelName: m}} + models[m] = &v1alpha2.InferenceModel{Spec: v1alpha2.InferenceModelSpec{ModelName: m}} } } diff --git a/pkg/epp/test/utils.go b/pkg/epp/test/utils.go index c44d7147..6a75ed2f 100644 --- a/pkg/epp/test/utils.go +++ b/pkg/epp/test/utils.go @@ -29,7 +29,7 @@ import ( "google.golang.org/grpc/reflection" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" @@ -43,7 +43,7 @@ func StartExtProc( port int, refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, pods []*datastore.PodMetrics, - models map[string]*v1alpha1.InferenceModel, + models map[string]*v1alpha2.InferenceModel, ) *grpc.Server { logger := log.FromContext(ctx) pms := make(map[types.NamespacedName]*datastore.PodMetrics) diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index c4342775..14ee738f 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -38,7 +38,7 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/config" - infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + infextv1a2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" testutils "sigs.k8s.io/gateway-api-inference-extension/test/utils" ) @@ -136,7 +136,7 @@ func setupSuite() { err = apiextv1.AddToScheme(scheme) gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) - err = infextv1a1.AddToScheme(scheme) + err = infextv1a2.AddToScheme(scheme) gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) cli, err = client.New(cfg, client.Options{Scheme: scheme}) diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 087097a7..8cd73d32 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -26,7 +26,7 @@ import ( "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" - infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" testutils "sigs.k8s.io/gateway-api-inference-extension/test/utils" ) @@ -95,8 +95,8 @@ var _ = ginkgo.Describe("InferencePool", func() { }) // newInferenceModel creates an InferenceModel in the given namespace for testutils. -func newInferenceModel(ns string) *infextv1a1.InferenceModel { - targets := []infextv1a1.TargetModel{ +func newInferenceModel(ns string) *v1alpha2.InferenceModel { + targets := []v1alpha2.TargetModel{ { Name: modelName + "-0", Weight: ptr.To(int32(50)), @@ -107,7 +107,7 @@ func newInferenceModel(ns string) *infextv1a1.InferenceModel { }, } return testutils.MakeModelWrapper("inferencemodel-sample", ns). - SetCriticality(infextv1a1.Critical). + SetCriticality(v1alpha2.Critical). SetModelName(modelName). SetPoolRef(modelServerName). SetTargetModels(targets). diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 91bc71c6..85c49913 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -46,7 +46,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" @@ -407,7 +407,7 @@ func BeforeSuit(t *testing.T) func() { } utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.AddToScheme(scheme)) k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) if err != nil { @@ -450,7 +450,7 @@ func BeforeSuit(t *testing.T) func() { } for _, doc := range docs { - inferenceModel := &v1alpha1.InferenceModel{} + inferenceModel := &v1alpha2.InferenceModel{} if err = yaml.Unmarshal(doc, inferenceModel); err != nil { logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) } @@ -462,7 +462,7 @@ func BeforeSuit(t *testing.T) func() { } } for _, doc := range docs { - inferencePool := &v1alpha1.InferencePool{} + inferencePool := &v1alpha2.InferencePool{} if err = yaml.Unmarshal(doc, inferencePool); err != nil { logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) } diff --git a/test/utils/utils.go b/test/utils/utils.go index 777eadd8..1ec0fbaa 100644 --- a/test/utils/utils.go +++ b/test/utils/utils.go @@ -36,7 +36,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/remotecommand" "sigs.k8s.io/controller-runtime/pkg/client" - infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) // DeleteClusterResources deletes all cluster-scoped objects the tests typically create. @@ -106,11 +106,11 @@ func DeleteNamespacedResources(ctx context.Context, cli client.Client, ns string if err != nil && !apierrors.IsNotFound(err) { return err } - err = cli.DeleteAllOf(ctx, &infextv1a1.InferencePool{}, client.InNamespace(ns), client.PropagationPolicy(metav1.DeletePropagationForeground)) + err = cli.DeleteAllOf(ctx, &v1alpha2.InferencePool{}, client.InNamespace(ns), client.PropagationPolicy(metav1.DeletePropagationForeground)) if err != nil && !apierrors.IsNotFound(err) { return err } - err = cli.DeleteAllOf(ctx, &infextv1a1.InferenceModel{}, client.InNamespace(ns), client.PropagationPolicy(metav1.DeletePropagationForeground)) + err = cli.DeleteAllOf(ctx, &v1alpha2.InferenceModel{}, client.InNamespace(ns), client.PropagationPolicy(metav1.DeletePropagationForeground)) if err != nil && !apierrors.IsNotFound(err) { return err } @@ -132,7 +132,7 @@ func DeleteInferenceModelResources(ctx context.Context, cli client.Client, ns st if ns == "" { return nil } - err := cli.DeleteAllOf(ctx, &infextv1a1.InferenceModel{}, client.InNamespace(ns), client.PropagationPolicy(metav1.DeletePropagationForeground)) + err := cli.DeleteAllOf(ctx, &v1alpha2.InferenceModel{}, client.InNamespace(ns), client.PropagationPolicy(metav1.DeletePropagationForeground)) if err != nil && !apierrors.IsNotFound(err) { return err } diff --git a/test/utils/wrappers.go b/test/utils/wrappers.go index 668a5adc..3280cb11 100644 --- a/test/utils/wrappers.go +++ b/test/utils/wrappers.go @@ -18,25 +18,25 @@ package utils import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - infextv1a1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) // InferenceModelWrapper wraps an InferenceModel. type InferenceModelWrapper struct { - infextv1a1.InferenceModel + v1alpha2.InferenceModel } // MakeModelWrapper creates a wrapper for an MakeModelWrapper. func MakeModelWrapper(name, ns string) *InferenceModelWrapper { return &InferenceModelWrapper{ - infextv1a1.InferenceModel{ + v1alpha2.InferenceModel{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: ns, }, - Spec: infextv1a1.InferenceModelSpec{ + Spec: v1alpha2.InferenceModelSpec{ ModelName: "", - PoolRef: infextv1a1.PoolObjectReference{}, + PoolRef: v1alpha2.PoolObjectReference{}, }, }, } @@ -49,7 +49,7 @@ func (m *InferenceModelWrapper) SetModelName(name string) *InferenceModelWrapper } // SetCriticality sets the value of the inferenceModel.spec.criticality. -func (m *InferenceModelWrapper) SetCriticality(level infextv1a1.Criticality) *InferenceModelWrapper { +func (m *InferenceModelWrapper) SetCriticality(level v1alpha2.Criticality) *InferenceModelWrapper { m.Spec.Criticality = &level return m } @@ -57,8 +57,8 @@ func (m *InferenceModelWrapper) SetCriticality(level infextv1a1.Criticality) *In // SetPoolRef sets the value of the inferenceModel.spec.poolRef using defaults // for group/kind and name as the PoolObjectReference name. func (m *InferenceModelWrapper) SetPoolRef(name string) *InferenceModelWrapper { - ref := infextv1a1.PoolObjectReference{ - Group: infextv1a1.GroupVersion.Group, + ref := v1alpha2.PoolObjectReference{ + Group: v1alpha2.GroupVersion.Group, Kind: "inferencepools", Name: name, } @@ -67,12 +67,12 @@ func (m *InferenceModelWrapper) SetPoolRef(name string) *InferenceModelWrapper { } // SetTargetModels sets the value of the inferenceModel.spec.targetModels. -func (m *InferenceModelWrapper) SetTargetModels(models []infextv1a1.TargetModel) *InferenceModelWrapper { +func (m *InferenceModelWrapper) SetTargetModels(models []v1alpha2.TargetModel) *InferenceModelWrapper { m.Spec.TargetModels = models return m } // Obj returns the inner InferenceModel. -func (m *InferenceModelWrapper) Obj() *infextv1a1.InferenceModel { +func (m *InferenceModelWrapper) Obj() *v1alpha2.InferenceModel { return &m.InferenceModel } From c25f0c98609362e73ae0f0f6bfdfbb58a5390468 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:00:28 -0700 Subject: [PATCH 049/260] Adding a slim roadmap to README (#400) --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e6730ae4..c500602c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,16 @@ See our website at https://gateway-api-inference-extension.sigs.k8s.io/ for deta ## Roadmap -Coming soon! +As Inference Gateway builds towards a GA release. We will continue to expand our capabilities, namely: +1. Prefix-cache aware load balancing with interfaces for remote caches +1. Recommended LoRA adapter pipeline for automated rollout +1. Fairness and priority between workloads within the same criticality band +1. HPA support for autoscaling on aggregate metrics derived from the load balancer +1. Support for large multi-modal inputs and outputs +1. Support for other GenAI model types (diffusion and other non-completion protocols) +1. Heterogeneous accelerators - serve workloads on multiple types of accelerator using latency and request cost-aware load balancing +1. Disaggregated serving support with independently scaling pools + ## End-to-End Tests From 45f9898ea02a26a4f36c5e411f04e9fc1bcf8e5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:40:28 -0800 Subject: [PATCH 050/260] Bump github.com/prometheus/client_golang from 1.20.5 to 1.21.0 (#402) Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.5 to 1.21.0. - [Release notes](https://github.com/prometheus/client_golang/releases) - [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md) - [Commits](https://github.com/prometheus/client_golang/compare/v1.20.5...v1.21.0) --- updated-dependencies: - dependency-name: github.com/prometheus/client_golang dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index ca4a1633..09af73d8 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/jhump/protoreflect v1.17.0 github.com/onsi/ginkgo/v2 v2.22.2 github.com/onsi/gomega v1.36.2 - github.com/prometheus/client_golang v1.20.5 + github.com/prometheus/client_golang v1.21.0 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.62.0 github.com/stretchr/testify v1.10.0 @@ -85,7 +85,7 @@ require ( github.com/jinzhu/configor v1.2.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index 2d54aba2..8bb93777 100644 --- a/go.sum +++ b/go.sum @@ -138,8 +138,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -190,8 +190,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= +github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= From 40d024baabf3e4e174dc2c9884511bd27f50606e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:52:29 -0800 Subject: [PATCH 051/260] Bump github.com/google/go-cmp from 0.6.0 to 0.7.0 (#403) Bumps [github.com/google/go-cmp](https://github.com/google/go-cmp) from 0.6.0 to 0.7.0. - [Release notes](https://github.com/google/go-cmp/releases) - [Commits](https://github.com/google/go-cmp/compare/v0.6.0...v0.7.0) --- updated-dependencies: - dependency-name: github.com/google/go-cmp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 09af73d8..91173449 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/elastic/crd-ref-docs v0.1.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/jhump/protoreflect v1.17.0 github.com/onsi/ginkgo/v2 v2.22.2 github.com/onsi/gomega v1.36.2 diff --git a/go.sum b/go.sum index 8bb93777..f55f404b 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/google/cel-go v0.22.0/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= From 2a88b3bbb1344bf0558ea6915f9441becd1c12e1 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 25 Feb 2025 18:44:31 +0200 Subject: [PATCH 052/260] updated logging in inferencepool reconciler (#399) * updated logging + predicate in inferencepool reconciler Signed-off-by: Nir Rozenbaum * removed irrelevant unit test. after adding predicate to the controller registration, the reconcile function no longer contains the filtering logic. therefore, it's not relecant to test the reconcile function with pool2. in runtime, this event will be filtered out much earlier Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- .../controller/inferencepool_reconciler.go | 27 ++++++++++--------- .../inferencepool_reconciler_test.go | 14 +++------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/pkg/epp/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go index baf3332b..2ad7d2bb 100644 --- a/pkg/epp/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -27,6 +27,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -44,31 +45,28 @@ type InferencePoolReconciler struct { } func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - if req.NamespacedName.Name != c.PoolNamespacedName.Name || req.NamespacedName.Namespace != c.PoolNamespacedName.Namespace { - return ctrl.Result{}, nil - } + logger := log.FromContext(ctx).WithValues("inferencePool", req.NamespacedName).V(logutil.DEFAULT) + ctx = ctrl.LoggerInto(ctx, logger) - logger := log.FromContext(ctx) - loggerDefault := logger.V(logutil.DEFAULT) - loggerDefault.Info("Reconciling InferencePool", "name", req.NamespacedName) + logger.Info("Reconciling InferencePool") - serverPool := &v1alpha2.InferencePool{} + infPool := &v1alpha2.InferencePool{} - if err := c.Get(ctx, req.NamespacedName, serverPool); err != nil { + if err := c.Get(ctx, req.NamespacedName, infPool); err != nil { if errors.IsNotFound(err) { - loggerDefault.Info("InferencePool not found. Clearing the datastore", "name", req.NamespacedName) + logger.Info("InferencePool not found. Clearing the datastore") c.Datastore.Clear() return ctrl.Result{}, nil } - loggerDefault.Error(err, "Unable to get InferencePool", "name", req.NamespacedName) + logger.Error(err, "Unable to get InferencePool") return ctrl.Result{}, err - } else if !serverPool.DeletionTimestamp.IsZero() { - loggerDefault.Info("InferencePool is marked for deletion. Clearing the datastore", "name", req.NamespacedName) + } else if !infPool.DeletionTimestamp.IsZero() { + logger.Info("InferencePool is marked for deletion. Clearing the datastore") c.Datastore.Clear() return ctrl.Result{}, nil } - c.updateDatastore(ctx, serverPool) + c.updateDatastore(ctx, infPool) return ctrl.Result{}, nil } @@ -92,5 +90,8 @@ func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool * func (c *InferencePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha2.InferencePool{}). + WithEventFilter(predicate.NewPredicateFuncs(func(object client.Object) bool { + return (object.GetNamespace() == c.PoolNamespacedName.Namespace) && (object.GetName() == c.PoolNamespacedName.Name) + })). Complete(c) } diff --git a/pkg/epp/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go index a96406f0..26b81d9a 100644 --- a/pkg/epp/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -102,15 +102,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { t.Errorf("Unexpected diff (+got/-want): %s", diff) } - // Step 2: A reconcile on pool2 should not change anything. - if _, err := inferencePoolReconciler.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: pool2.Name, Namespace: pool2.Namespace}}); err != nil { - t.Errorf("Unexpected InferencePool reconcile error: %v", err) - } - if diff := diffPool(datastore, pool1, []string{"pod1", "pod2"}); diff != "" { - t.Errorf("Unexpected diff (+got/-want): %s", diff) - } - - // Step 3: update the pool selector to include more pods + // Step 2: update the pool selector to include more pods newPool1 := &v1alpha2.InferencePool{} if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { t.Errorf("Unexpected pool get error: %v", err) @@ -127,7 +119,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { t.Errorf("Unexpected diff (+got/-want): %s", diff) } - // Step 4: update the pool port + // Step 3: update the pool port if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { t.Errorf("Unexpected pool get error: %v", err) } @@ -142,7 +134,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { t.Errorf("Unexpected diff (+got/-want): %s", diff) } - // Step 5: delete the pool to trigger a datastore clear + // Step 4: delete the pool to trigger a datastore clear if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { t.Errorf("Unexpected pool get error: %v", err) } From 2ad70e34a9e973dd011466f2d5b82d80465825b9 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 25 Feb 2025 18:58:31 +0200 Subject: [PATCH 053/260] updated inferencemodel predicate (#397) Signed-off-by: Nir Rozenbaum --- cmd/epp/main.go | 4 ++-- .../controller/inferencemodel_reconciler.go | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index dd47fa27..5d399a42 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -166,7 +166,7 @@ func run() error { Provider: provider, } if err := serverRunner.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "Failed to setup ext-proc server") + setupLog.Error(err, "Failed to setup ext-proc controllers") return err } @@ -177,7 +177,7 @@ func run() error { // Register ext-proc server. if err := mgr.Add(serverRunner.AsRunnable(ctrl.Log.WithName("ext-proc"))); err != nil { - setupLog.Error(err, "Failed to register ext-proc server") + setupLog.Error(err, "Failed to register ext-proc gRPC server") return err } diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index 00358740..9de77989 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -26,7 +26,9 @@ import ( "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -41,10 +43,6 @@ type InferenceModelReconciler struct { } func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - if req.Namespace != c.PoolNamespacedName.Namespace { - return ctrl.Result{}, nil - } - logger := log.FromContext(ctx) loggerDefault := logger.V(logutil.DEFAULT) loggerDefault.Info("Reconciling InferenceModel", "name", req.NamespacedName) @@ -85,5 +83,17 @@ func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel func (c *InferenceModelReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha2.InferenceModel{}). + WithEventFilter(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { return c.eventPredicate(e.Object.(*v1alpha2.InferenceModel)) }, + UpdateFunc: func(e event.UpdateEvent) bool { + return c.eventPredicate(e.ObjectOld.(*v1alpha2.InferenceModel)) || c.eventPredicate(e.ObjectNew.(*v1alpha2.InferenceModel)) + }, + DeleteFunc: func(e event.DeleteEvent) bool { return c.eventPredicate(e.Object.(*v1alpha2.InferenceModel)) }, + GenericFunc: func(e event.GenericEvent) bool { return c.eventPredicate(e.Object.(*v1alpha2.InferenceModel)) }, + }). Complete(c) } + +func (c *InferenceModelReconciler) eventPredicate(infModel *v1alpha2.InferenceModel) bool { + return (infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name) && (infModel.GetNamespace() == c.PoolNamespacedName.Namespace) +} From 9bee374d76f7c126aad5cda89349815e4d1764f8 Mon Sep 17 00:00:00 2001 From: kfswain <137822113+kfswain@users.noreply.github.com> Date: Wed, 26 Feb 2025 10:36:30 -0700 Subject: [PATCH 054/260] Syncing readme all to main (#410) --- site-src/guides/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 4478128f..e0593f3b 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -23,7 +23,8 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Install the Inference Extension CRDs ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/v0.1.0/manifests.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml ``` ### Deploy InferenceModel From 7f804ae0bcac8ab8ec580836e29327a9a23ade08 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 26 Feb 2025 20:38:30 +0200 Subject: [PATCH 055/260] fixed the filepath (#412) Signed-off-by: Nir Rozenbaum --- site-src/guides/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index e0593f3b..2949d387 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -7,7 +7,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv - A cluster with: - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - - 3 GPUs to run the sample model server. Adjust the number of replicas in `./manifests/vllm/deployment.yaml` as needed. + - 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/deployment.yaml` as needed. ## **Steps** From 7ed54a4c2b9db9d0c7c95bfaa99c466080e5bb24 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Wed, 26 Feb 2025 19:08:30 +0000 Subject: [PATCH 056/260] Fix InferenceModel deletion logic (#393) * Currently the logic tracks the models by Spec.ModelName, since this is not guaranteed to be unique within the cluster, we could run into two issues: 1) If the model name changes on the same InferenceModel object, we don't delete the original model entry in the datastore. 2) We don't enforce the semantics that the modelName with the oldest creation timestamp is retained. While the api is assuming that this is enforced by another controller via the Ready condition, we don't have this controller yet, and so currently the behavior is unpredictable depending on InferenceModel events order. To address the above, the PR makes changes to both the InferenceModel reconciler and the Model APIs in the datastore to ensure thread safe updates of the entries. In the store, the sync.Map was replaced with two maps to track the InferenceModel entries by both ModelName and InferenceModel object NamespacedName. This is needed to properly handle deletions when the object doesn't exist anymore (could be handled in other ways, but this seemed like a reasonable approach). The PR increases the datastore pkg unit test coverage the Pool and Model APIs. We still need to followup with adding unit test coverage to the pods APIs, which is currently non-existent. * Convert unit test to a table * remove the dual map for the models store, and rely on linear search when looking up the model by object name * Added ModelResync to handle a race condition * Update pkg/epp/controller/inferencemodel_reconciler.go --- cmd/epp/main.go | 6 +- pkg/epp/backend/provider_test.go | 50 +- .../controller/inferencemodel_reconciler.go | 86 +++- .../inferencemodel_reconciler_test.go | 439 +++++++----------- .../inferencepool_reconciler_test.go | 93 ++-- pkg/epp/controller/pod_reconciler.go | 2 +- pkg/epp/controller/pod_reconciler_test.go | 148 ++---- pkg/epp/datastore/datastore.go | 145 ++++-- pkg/epp/datastore/datastore_test.go | 195 +++++++- pkg/epp/server/runserver.go | 4 +- pkg/epp/test/utils.go | 9 +- pkg/epp/util/testing/diff.go | 27 ++ pkg/epp/util/testing/wrappers.go | 117 ++++- test/e2e/e2e_suite_test.go | 5 - test/integration/hermetic_test.go | 9 +- 15 files changed, 789 insertions(+), 546 deletions(-) create mode 100644 pkg/epp/util/testing/diff.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 5d399a42..b66024ec 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -149,6 +149,8 @@ func run() error { return err } + ctx := ctrl.SetupSignalHandler() + // Setup runner. datastore := datastore.NewDatastore() provider := backend.NewProvider(&vllm.PodMetricsClientImpl{}, datastore) @@ -165,7 +167,7 @@ func run() error { CertPath: *certPath, Provider: provider, } - if err := serverRunner.SetupWithManager(mgr); err != nil { + if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { setupLog.Error(err, "Failed to setup ext-proc controllers") return err } @@ -188,7 +190,7 @@ func run() error { // Start the manager. This blocks until a signal is received. setupLog.Info("Controller manager starting") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "Error starting controller manager") return err } diff --git a/pkg/epp/backend/provider_test.go b/pkg/epp/backend/provider_test.go index 1e11afe2..f2db09fe 100644 --- a/pkg/epp/backend/provider_test.go +++ b/pkg/epp/backend/provider_test.go @@ -19,7 +19,6 @@ package backend import ( "context" "errors" - "sync" "testing" "time" @@ -37,6 +36,9 @@ var ( Name: "pod1", }, }, + } + pod1WithMetrics = &datastore.PodMetrics{ + Pod: pod1.Pod, Metrics: datastore.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -53,6 +55,9 @@ var ( Name: "pod2", }, }, + } + pod2WithMetrics = &datastore.PodMetrics{ + Pod: pod2.Pod, Metrics: datastore.Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.2, @@ -69,35 +74,30 @@ func TestProvider(t *testing.T) { tests := []struct { name string pmc PodMetricsClient - datastore datastore.Datastore + storePods []*datastore.PodMetrics want []*datastore.PodMetrics }{ { name: "Probing metrics success", pmc: &FakePodMetricsClient{ Res: map[types.NamespacedName]*datastore.PodMetrics{ - pod1.NamespacedName: pod1, - pod2.NamespacedName: pod2, + pod1.NamespacedName: pod1WithMetrics, + pod2.NamespacedName: pod2WithMetrics, }, }, - datastore: datastore.NewFakeDatastore(populateMap(pod1, pod2), nil, nil), - want: []*datastore.PodMetrics{ - pod1, - pod2, - }, + storePods: []*datastore.PodMetrics{pod1, pod2}, + want: []*datastore.PodMetrics{pod1WithMetrics, pod2WithMetrics}, }, { name: "Only pods in the datastore are probed", pmc: &FakePodMetricsClient{ Res: map[types.NamespacedName]*datastore.PodMetrics{ - pod1.NamespacedName: pod1, - pod2.NamespacedName: pod2, + pod1.NamespacedName: pod1WithMetrics, + pod2.NamespacedName: pod2WithMetrics, }, }, - datastore: datastore.NewFakeDatastore(populateMap(pod1), nil, nil), - want: []*datastore.PodMetrics{ - pod1, - }, + storePods: []*datastore.PodMetrics{pod1}, + want: []*datastore.PodMetrics{pod1WithMetrics}, }, { name: "Probing metrics error", @@ -106,13 +106,12 @@ func TestProvider(t *testing.T) { pod2.NamespacedName: errors.New("injected error"), }, Res: map[types.NamespacedName]*datastore.PodMetrics{ - pod1.NamespacedName: pod1, + pod1.NamespacedName: pod1WithMetrics, }, }, - datastore: datastore.NewFakeDatastore(populateMap(pod1, pod2), nil, nil), - + storePods: []*datastore.PodMetrics{pod1, pod2}, want: []*datastore.PodMetrics{ - pod1, + pod1WithMetrics, // Failed to fetch pod2 metrics so it remains the default values. { Pod: datastore.Pod{NamespacedName: pod2.NamespacedName}, @@ -128,12 +127,13 @@ func TestProvider(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - p := NewProvider(test.pmc, test.datastore) + ds := datastore.NewFakeDatastore(test.storePods, nil, nil) + p := NewProvider(test.pmc, ds) ctx, cancel := context.WithCancel(context.Background()) defer cancel() _ = p.Init(ctx, time.Millisecond, time.Millisecond) assert.EventuallyWithT(t, func(t *assert.CollectT) { - metrics := test.datastore.PodGetAll() + metrics := ds.PodGetAll() diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(func(a, b *datastore.PodMetrics) bool { return a.String() < b.String() })) @@ -142,11 +142,3 @@ func TestProvider(t *testing.T) { }) } } - -func populateMap(pods ...*datastore.PodMetrics) *sync.Map { - newMap := &sync.Map{} - for _, pod := range pods { - newMap.Store(pod.NamespacedName, &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: pod.NamespacedName, Address: pod.Address}}) - } - return newMap -} diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index 9de77989..7cf18808 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -18,8 +18,8 @@ package controller import ( "context" + "fmt" - "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -43,44 +43,80 @@ type InferenceModelReconciler struct { } func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - loggerDefault := logger.V(logutil.DEFAULT) - loggerDefault.Info("Reconciling InferenceModel", "name", req.NamespacedName) + if req.Namespace != c.PoolNamespacedName.Namespace { + return ctrl.Result{}, nil + } + logger := log.FromContext(ctx).V(logutil.DEFAULT).WithValues("inferenceModel", req.Name) + ctx = ctrl.LoggerInto(ctx, logger) + + logger.Info("Reconciling InferenceModel") infModel := &v1alpha2.InferenceModel{} + notFound := false if err := c.Get(ctx, req.NamespacedName, infModel); err != nil { - if errors.IsNotFound(err) { - loggerDefault.Info("InferenceModel not found. Removing from datastore since object must be deleted", "name", req.NamespacedName) - c.Datastore.ModelDelete(infModel.Spec.ModelName) - return ctrl.Result{}, nil + if !errors.IsNotFound(err) { + logger.Error(err, "Unable to get InferenceModel") + return ctrl.Result{}, err } - loggerDefault.Error(err, "Unable to get InferenceModel", "name", req.NamespacedName) + notFound = true + } + + if notFound || !infModel.DeletionTimestamp.IsZero() || infModel.Spec.PoolRef.Name != c.PoolNamespacedName.Name { + // InferenceModel object got deleted or changed the referenced pool. + err := c.handleModelDeleted(ctx, req.NamespacedName) return ctrl.Result{}, err - } else if !infModel.DeletionTimestamp.IsZero() { - loggerDefault.Info("InferenceModel is marked for deletion. Removing from datastore", "name", req.NamespacedName) - c.Datastore.ModelDelete(infModel.Spec.ModelName) - return ctrl.Result{}, nil } - c.updateDatastore(logger, infModel) + // Add or update if the InferenceModel instance has a creation timestamp older than the existing entry of the model. + logger = logger.WithValues("poolRef", infModel.Spec.PoolRef).WithValues("modelName", infModel.Spec.ModelName) + if !c.Datastore.ModelSetIfOlder(infModel) { + logger.Info("Skipping InferenceModel, existing instance has older creation timestamp") + + } + logger.Info("Added/Updated InferenceModel") + return ctrl.Result{}, nil } -func (c *InferenceModelReconciler) updateDatastore(logger logr.Logger, infModel *v1alpha2.InferenceModel) { - loggerDefault := logger.V(logutil.DEFAULT) +func (c *InferenceModelReconciler) handleModelDeleted(ctx context.Context, req types.NamespacedName) error { + logger := log.FromContext(ctx) - if infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name { - loggerDefault.Info("Updating datastore", "poolRef", infModel.Spec.PoolRef, "serverPoolName", c.PoolNamespacedName) - loggerDefault.Info("Adding/Updating InferenceModel", "modelName", infModel.Spec.ModelName) - c.Datastore.ModelSet(infModel) - return + // We will lookup and delete the modelName associated with this object, and search for + // other instances referencing the same modelName if exist, and store the oldest in + // its place. This ensures that the InferenceModel with the oldest creation + // timestamp is active. + existing, exists := c.Datastore.ModelDelete(req) + if !exists { + // No entry exists in the first place, nothing to do. + return nil + } + logger.Info("InferenceModel removed from datastore", "poolRef", existing.Spec.PoolRef, "modelName", existing.Spec.ModelName) + + // TODO(#409): replace this backfill logic with one that is based on InferenceModel Ready conditions once those are set by an external controller. + updated, err := c.Datastore.ModelResync(ctx, c.Client, existing.Spec.ModelName) + if err != nil { + return err + } + if updated { + logger.Info("Model replaced.", "modelName", existing.Spec.ModelName) } - loggerDefault.Info("Removing/Not adding InferenceModel", "modelName", infModel.Spec.ModelName) - // If we get here. The model is not relevant to this pool, remove. - c.Datastore.ModelDelete(infModel.Spec.ModelName) + return nil } -func (c *InferenceModelReconciler) SetupWithManager(mgr ctrl.Manager) error { +func indexInferenceModelsByModelName(obj client.Object) []string { + m, ok := obj.(*v1alpha2.InferenceModel) + if !ok { + return nil + } + return []string{m.Spec.ModelName} +} + +func (c *InferenceModelReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + // Create an index on ModelName for InferenceModel objects. + indexer := mgr.GetFieldIndexer() + if err := indexer.IndexField(ctx, &v1alpha2.InferenceModel{}, datastore.ModelNameIndexKey, indexInferenceModelsByModelName); err != nil { + return fmt.Errorf("setting index on ModelName for InferenceModel: %w", err) + } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha2.InferenceModel{}). WithEventFilter(predicate.Funcs{ diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index cea7bf42..87323e80 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -18,302 +18,219 @@ package controller import ( "context" - "sync" "testing" + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + utiltest "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) var ( - infModel1 = &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - ModelName: "fake model1", - PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, + pool = utiltest.MakeInferencePool("test-pool1").Namespace("ns1").ObjRef() + infModel1 = utiltest.MakeInferenceModel("model1"). + Namespace(pool.Namespace). + ModelName("fake model1"). + Criticality(v1alpha2.Standard). + CreationTimestamp(metav1.Unix(1000, 0)). + PoolName(pool.Name).ObjRef() + infModel1Pool2 = utiltest.MakeInferenceModel(infModel1.Name). + Namespace(infModel1.Namespace). + ModelName(infModel1.Spec.ModelName). + Criticality(*infModel1.Spec.Criticality). + CreationTimestamp(metav1.Unix(1001, 0)). + PoolName("test-pool2").ObjRef() + infModel1NS2 = utiltest.MakeInferenceModel(infModel1.Name). + Namespace("ns2"). + ModelName(infModel1.Spec.ModelName). + Criticality(*infModel1.Spec.Criticality). + CreationTimestamp(metav1.Unix(1002, 0)). + PoolName(pool.Name).ObjRef() + infModel1Critical = utiltest.MakeInferenceModel(infModel1.Name). + Namespace(infModel1.Namespace). + ModelName(infModel1.Spec.ModelName). + Criticality(v1alpha2.Critical). + CreationTimestamp(metav1.Unix(1003, 0)). + PoolName(pool.Name).ObjRef() + infModel1Deleted = utiltest.MakeInferenceModel(infModel1.Name). + Namespace(infModel1.Namespace). + ModelName(infModel1.Spec.ModelName). + CreationTimestamp(metav1.Unix(1004, 0)). + DeletionTimestamp(). + PoolName(pool.Name).ObjRef() + // Same ModelName, different object with newer creation timestamp + infModel1Newer = utiltest.MakeInferenceModel("model1-newer"). + Namespace(pool.Namespace). + ModelName("fake model1"). + Criticality(v1alpha2.Standard). + CreationTimestamp(metav1.Unix(1005, 0)). + PoolName(pool.Name).ObjRef() + // Same ModelName, different object with older creation timestamp + infModel1Older = utiltest.MakeInferenceModel("model1-older"). + Namespace(pool.Namespace). + ModelName("fake model1"). + Criticality(v1alpha2.Standard). + CreationTimestamp(metav1.Unix(999, 0)). + PoolName(pool.Name).ObjRef() + + infModel2 = utiltest.MakeInferenceModel("model2"). + Namespace(pool.Namespace). + ModelName("fake model2"). + CreationTimestamp(metav1.Unix(1000, 0)). + PoolName(pool.Name).ObjRef() + infModel2NS2 = utiltest.MakeInferenceModel(infModel2.Name). + Namespace("ns2"). + ModelName(infModel2.Spec.ModelName). + CreationTimestamp(metav1.Unix(1000, 0)). + PoolName(pool.Name).ObjRef() +) + +func TestInferenceModelReconciler(t *testing.T) { + tests := []struct { + name string + modelsInStore []*v1alpha2.InferenceModel + modelsInAPIServer []*v1alpha2.InferenceModel + model *v1alpha2.InferenceModel + incomingReq *types.NamespacedName + wantModels []*v1alpha2.InferenceModel + wantResult ctrl.Result + }{ + { + name: "Empty store, add new model", + model: infModel1, + wantModels: []*v1alpha2.InferenceModel{infModel1}, }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-service", + { + name: "Existing model changed pools", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1Pool2, + wantModels: []*v1alpha2.InferenceModel{}, }, - } - infModel1Modified = &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - ModelName: "fake model1", - PoolRef: v1alpha2.PoolObjectReference{Name: "test-poolio"}, + { + name: "Not found, delete existing model", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + incomingReq: &types.NamespacedName{Name: infModel1.Name, Namespace: infModel1.Namespace}, + wantModels: []*v1alpha2.InferenceModel{}, }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-service", + { + name: "Deletion timestamp set, delete existing model", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1Deleted, + wantModels: []*v1alpha2.InferenceModel{}, }, - } - infModel2 = &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - ModelName: "fake model", - PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, + { + name: "Model referencing a different pool, different pool name but same namespace", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1NS2, + wantModels: []*v1alpha2.InferenceModel{infModel1}, }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-service-2", + { + name: "Model referencing a different pool, same pool name but different namespace", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel2NS2, + wantModels: []*v1alpha2.InferenceModel{infModel1}, }, - } -) - -func TestUpdateDatastore_InferenceModelReconciler(t *testing.T) { - logger := logutil.NewTestLogger() - - tests := []struct { - name string - datastore datastore.Datastore - incomingService *v1alpha2.InferenceModel - wantInferenceModels *sync.Map - }{ { - name: "No Services registered; valid, new service incoming.", - datastore: datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ - Spec: v1alpha2.InferencePoolSpec{ - Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, - }), - - incomingService: infModel1, - wantInferenceModels: populateServiceMap(infModel1), + name: "Existing model changed pools, replaced with another", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1Pool2, + modelsInAPIServer: []*v1alpha2.InferenceModel{infModel1Newer}, + wantModels: []*v1alpha2.InferenceModel{infModel1Newer}, + }, + { + name: "Not found, delete existing model, replaced with another", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + incomingReq: &types.NamespacedName{Name: infModel1.Name, Namespace: infModel1.Namespace}, + modelsInAPIServer: []*v1alpha2.InferenceModel{infModel1Newer}, + wantModels: []*v1alpha2.InferenceModel{infModel1Newer}, + }, + { + name: "Deletion timestamp set, delete existing model, replaced with another", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1Deleted, + modelsInAPIServer: []*v1alpha2.InferenceModel{infModel1Newer}, + wantModels: []*v1alpha2.InferenceModel{infModel1Newer}, }, { - name: "Removing existing service.", - datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha2.InferencePool{ - Spec: v1alpha2.InferencePoolSpec{ - Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, - }), - incomingService: infModel1Modified, - wantInferenceModels: populateServiceMap(), + name: "Older instance of the model observed", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1Older, + wantModels: []*v1alpha2.InferenceModel{infModel1Older}, }, { - name: "Unrelated service, do nothing.", - datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha2.InferencePool{ - Spec: v1alpha2.InferencePoolSpec{ - Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, - }), - incomingService: &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - ModelName: "fake model", - PoolRef: v1alpha2.PoolObjectReference{Name: "test-poolio"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "unrelated-service", - }, - }, - wantInferenceModels: populateServiceMap(infModel1), + name: "Model changed criticality", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel1Critical, + wantModels: []*v1alpha2.InferenceModel{infModel1Critical}, }, { - name: "Add to existing", - datastore: datastore.NewFakeDatastore(nil, populateServiceMap(infModel1), &v1alpha2.InferencePool{ - Spec: v1alpha2.InferencePoolSpec{ - Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm"}, - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pool", - ResourceVersion: "Old and boring", - }, - }), - incomingService: infModel2, - wantInferenceModels: populateServiceMap(infModel1, infModel2), + name: "Model not found, no matching existing model to delete", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + incomingReq: &types.NamespacedName{Name: "non-existent-model", Namespace: pool.Namespace}, + wantModels: []*v1alpha2.InferenceModel{infModel1}, + }, + { + name: "Add to existing", + modelsInStore: []*v1alpha2.InferenceModel{infModel1}, + model: infModel2, + wantModels: []*v1alpha2.InferenceModel{infModel1, infModel2}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - pool, err := test.datastore.PoolGet() - if err != nil { - t.Fatalf("failed to get pool: %v", err) + // Create a fake client with no InferenceModel objects. + scheme := runtime.NewScheme() + _ = v1alpha2.AddToScheme(scheme) + initObjs := []client.Object{} + if test.model != nil { + initObjs = append(initObjs, test.model) } - reconciler := &InferenceModelReconciler{ - Datastore: test.datastore, - PoolNamespacedName: types.NamespacedName{Name: pool.Name}, + for _, m := range test.modelsInAPIServer { + initObjs = append(initObjs, m) } - reconciler.updateDatastore(logger, test.incomingService) - - test.wantInferenceModels.Range(func(k, v any) bool { - _, exist := test.datastore.ModelGet(k.(string)) - if !exist { - t.Fatalf("failed to get model %s", k) - } - return true - }) - }) - } -} - -func TestReconcile_ResourceNotFound(t *testing.T) { - // Set up the scheme. - scheme := runtime.NewScheme() - _ = v1alpha2.AddToScheme(scheme) - - // Create a fake client with no InferenceModel objects. - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - - // Create a minimal datastore. - datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ - ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, - }) - - // Create the reconciler. - reconciler := &InferenceModelReconciler{ - Client: fakeClient, - Scheme: scheme, - Record: record.NewFakeRecorder(10), - Datastore: datastore, - PoolNamespacedName: types.NamespacedName{Name: "test-pool"}, - } - - // Create a request for a non-existent resource. - req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "non-existent-model", Namespace: "default"}} - - // Call Reconcile. - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("expected no error when resource is not found, got %v", err) - } - - // Check that no requeue is requested. - if result.Requeue || result.RequeueAfter != 0 { - t.Errorf("expected no requeue, got %+v", result) - } -} - -func TestReconcile_ModelMarkedForDeletion(t *testing.T) { - // Set up the scheme. - scheme := runtime.NewScheme() - _ = v1alpha2.AddToScheme(scheme) - - // Create an InferenceModel object. - now := metav1.Now() - existingModel := &v1alpha2.InferenceModel{ - ObjectMeta: metav1.ObjectMeta{ - Name: "existing-model", - Namespace: "default", - DeletionTimestamp: &now, - Finalizers: []string{"finalizer"}, - }, - Spec: v1alpha2.InferenceModelSpec{ - ModelName: "fake-model", - PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, - }, - } - - // Create a fake client with the existing model. - fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() - - // Create a minimal datastore. - datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ - ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, - }) - - // Create the reconciler. - reconciler := &InferenceModelReconciler{ - Client: fakeClient, - Scheme: scheme, - Record: record.NewFakeRecorder(10), - Datastore: datastore, - PoolNamespacedName: types.NamespacedName{Name: "test-pool", Namespace: "default"}, - } - - // Create a request for the existing resource. - req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "existing-model", Namespace: "default"}} - - // Call Reconcile. - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("expected no error when resource exists, got %v", err) - } - - // Check that no requeue is requested. - if result.Requeue || result.RequeueAfter != 0 { - t.Errorf("expected no requeue, got %+v", result) - } - - // Verify that the datastore was not updated. - if _, exist := datastore.ModelGet(existingModel.Spec.ModelName); exist { - t.Errorf("expected datastore to not contain model %q", existingModel.Spec.ModelName) - } -} - -func TestReconcile_ResourceExists(t *testing.T) { - // Set up the scheme. - scheme := runtime.NewScheme() - _ = v1alpha2.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(initObjs...). + WithIndex(&v1alpha2.InferenceModel{}, datastore.ModelNameIndexKey, indexInferenceModelsByModelName). + Build() - // Create an InferenceModel object. - existingModel := &v1alpha2.InferenceModel{ - ObjectMeta: metav1.ObjectMeta{ - Name: "existing-model", - Namespace: "default", - }, - Spec: v1alpha2.InferenceModelSpec{ - ModelName: "fake-model", - PoolRef: v1alpha2.PoolObjectReference{Name: "test-pool"}, - }, - } - - // Create a fake client with the existing model. - fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existingModel).Build() - - // Create a minimal datastore. - datastore := datastore.NewFakeDatastore(nil, nil, &v1alpha2.InferencePool{ - ObjectMeta: metav1.ObjectMeta{Name: "test-pool"}, - }) - - // Create the reconciler. - reconciler := &InferenceModelReconciler{ - Client: fakeClient, - Scheme: scheme, - Record: record.NewFakeRecorder(10), - Datastore: datastore, - PoolNamespacedName: types.NamespacedName{Name: "test-pool", Namespace: "default"}, - } - - // Create a request for the existing resource. - req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "existing-model", Namespace: "default"}} + datastore := datastore.NewFakeDatastore(nil, test.modelsInStore, pool) + reconciler := &InferenceModelReconciler{ + Client: fakeClient, + Scheme: scheme, + Record: record.NewFakeRecorder(10), + Datastore: datastore, + PoolNamespacedName: types.NamespacedName{Name: pool.Name, Namespace: pool.Namespace}, + } + if test.incomingReq == nil { + test.incomingReq = &types.NamespacedName{Name: test.model.Name, Namespace: test.model.Namespace} + } - // Call Reconcile. - result, err := reconciler.Reconcile(context.Background(), req) - if err != nil { - t.Fatalf("expected no error when resource exists, got %v", err) - } + // Call Reconcile. + result, err := reconciler.Reconcile(context.Background(), ctrl.Request{NamespacedName: *test.incomingReq}) + if err != nil { + t.Fatalf("expected no error when resource is not found, got %v", err) + } - // Check that no requeue is requested. - if result.Requeue || result.RequeueAfter != 0 { - t.Errorf("expected no requeue, got %+v", result) - } + if diff := cmp.Diff(result, test.wantResult); diff != "" { + t.Errorf("Unexpected result diff (+got/-want): %s", diff) + } - // Verify that the datastore was updated. - if _, exist := datastore.ModelGet(existingModel.Spec.ModelName); !exist { - t.Errorf("expected datastore to contain model %q", existingModel.Spec.ModelName) - } -} + if len(test.wantModels) != len(datastore.ModelGetAll()) { + t.Errorf("Unexpected; want: %d, got:%d", len(test.wantModels), len(datastore.ModelGetAll())) + } -func populateServiceMap(services ...*v1alpha2.InferenceModel) *sync.Map { - returnVal := &sync.Map{} + if diff := diffStore(datastore, diffStoreParams{wantPool: pool, wantModels: test.wantModels}); diff != "" { + t.Errorf("Unexpected diff (+got/-want): %s", diff) + } - for _, service := range services { - returnVal.Store(service.Spec.ModelName, service) + }) } - return returnVal } diff --git a/pkg/epp/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go index 26b81d9a..f35b8dc0 100644 --- a/pkg/epp/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -23,7 +23,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -32,42 +31,44 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" + utiltest "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) var ( selector_v1 = map[string]string{"app": "vllm_v1"} selector_v2 = map[string]string{"app": "vllm_v2"} - pool1 = &v1alpha2.InferencePool{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pool1", - Namespace: "pool1-ns", - }, - Spec: v1alpha2.InferencePoolSpec{ - Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{"app": "vllm_v1"}, - TargetPortNumber: 8080, - }, - } - pool2 = &v1alpha2.InferencePool{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pool2", - Namespace: "pool2-ns", - }, - } - pods = []corev1.Pod{ + pool1 = utiltest.MakeInferencePool("pool1"). + Namespace("pool1-ns"). + Selector(selector_v1). + TargetPortNumber(8080).ObjRef() + pool2 = utiltest.MakeInferencePool("pool2").Namespace("pool2-ns").ObjRef() + pods = []*corev1.Pod{ // Two ready pods matching pool1 - utiltesting.MakePod("pod1", "pool1-ns").Labels(selector_v1).ReadyCondition().Obj(), - utiltesting.MakePod("pod2", "pool1-ns").Labels(selector_v1).ReadyCondition().Obj(), + utiltest.MakePod("pod1"). + Namespace("pool1-ns"). + Labels(selector_v1).ReadyCondition().ObjRef(), + utiltest.MakePod("pod2"). + Namespace("pool1-ns"). + Labels(selector_v1). + ReadyCondition().ObjRef(), // A not ready pod matching pool1 - utiltesting.MakePod("pod3", "pool1-ns").Labels(selector_v1).Obj(), + utiltest.MakePod("pod3"). + Namespace("pool1-ns"). + Labels(selector_v1).ObjRef(), // A pod not matching pool1 namespace - utiltesting.MakePod("pod4", "pool2-ns").Labels(selector_v1).ReadyCondition().Obj(), + utiltest.MakePod("pod4"). + Namespace("pool2-ns"). + Labels(selector_v1). + ReadyCondition().ObjRef(), // A ready pod matching pool1 with a new selector - utiltesting.MakePod("pod5", "pool1-ns").Labels(selector_v2).ReadyCondition().Obj(), + utiltest.MakePod("pod5"). + Namespace("pool1-ns"). + Labels(selector_v2). + ReadyCondition().ObjRef(), } ) -func TestReconcile_InferencePoolReconciler(t *testing.T) { +func TestInferencePoolReconciler(t *testing.T) { // The best practice is to use table-driven tests, however in this scaenario it seems // more logical to do a single test with steps that depend on each other. @@ -79,7 +80,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { // Create a fake client with the pool and the pods. initialObjects := []client.Object{pool1, pool2} for i := range pods { - initialObjects = append(initialObjects, &pods[i]) + initialObjects = append(initialObjects, pods[i]) } fakeClient := fake.NewClientBuilder(). WithScheme(scheme). @@ -98,11 +99,10 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { t.Errorf("Unexpected InferencePool reconcile error: %v", err) } - if diff := diffPool(datastore, pool1, []string{"pod1", "pod2"}); diff != "" { + if diff := diffStore(datastore, diffStoreParams{wantPool: pool1, wantPods: []string{"pod1", "pod2"}}); diff != "" { t.Errorf("Unexpected diff (+got/-want): %s", diff) } - // Step 2: update the pool selector to include more pods newPool1 := &v1alpha2.InferencePool{} if err := fakeClient.Get(ctx, req.NamespacedName, newPool1); err != nil { t.Errorf("Unexpected pool get error: %v", err) @@ -115,7 +115,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { t.Errorf("Unexpected InferencePool reconcile error: %v", err) } - if diff := diffPool(datastore, newPool1, []string{"pod5"}); diff != "" { + if diff := diffStore(datastore, diffStoreParams{wantPool: newPool1, wantPods: []string{"pod5"}}); diff != "" { t.Errorf("Unexpected diff (+got/-want): %s", diff) } @@ -130,7 +130,7 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { t.Errorf("Unexpected InferencePool reconcile error: %v", err) } - if diff := diffPool(datastore, newPool1, []string{"pod5"}); diff != "" { + if diff := diffStore(datastore, diffStoreParams{wantPool: newPool1, wantPods: []string{"pod5"}}); diff != "" { t.Errorf("Unexpected diff (+got/-want): %s", diff) } @@ -144,19 +144,42 @@ func TestReconcile_InferencePoolReconciler(t *testing.T) { if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { t.Errorf("Unexpected InferencePool reconcile error: %v", err) } - if diff := diffPool(datastore, nil, []string{}); diff != "" { + if diff := diffStore(datastore, diffStoreParams{wantPods: []string{}}); diff != "" { t.Errorf("Unexpected diff (+got/-want): %s", diff) } } -func diffPool(datastore datastore.Datastore, wantPool *v1alpha2.InferencePool, wantPods []string) string { +type diffStoreParams struct { + wantPool *v1alpha2.InferencePool + wantPods []string + wantModels []*v1alpha2.InferenceModel +} + +func diffStore(datastore datastore.Datastore, params diffStoreParams) string { gotPool, _ := datastore.PoolGet() - if diff := cmp.Diff(wantPool, gotPool); diff != "" { - return diff + if diff := cmp.Diff(params.wantPool, gotPool); diff != "" { + return "pool:" + diff + } + + // Default wantPods if not set because PodGetAll returns an empty slice when empty. + if params.wantPods == nil { + params.wantPods = []string{} } gotPods := []string{} for _, pm := range datastore.PodGetAll() { gotPods = append(gotPods, pm.NamespacedName.Name) } - return cmp.Diff(wantPods, gotPods, cmpopts.SortSlices(func(a, b string) bool { return a < b })) + if diff := cmp.Diff(params.wantPods, gotPods, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { + return "pods:" + diff + } + + // Default wantModels if not set because ModelGetAll returns an empty slice when empty. + if params.wantModels == nil { + params.wantModels = []*v1alpha2.InferenceModel{} + } + gotModels := datastore.ModelGetAll() + if diff := utiltest.DiffModelLists(params.wantModels, gotModels); diff != "" { + return "models:" + diff + } + return "" } diff --git a/pkg/epp/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go index 5b0c25c9..717d9f60 100644 --- a/pkg/epp/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -75,7 +75,7 @@ func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod) { namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} if !pod.DeletionTimestamp.IsZero() || !c.Datastore.PoolLabelsMatch(pod.Labels) || !podIsReady(pod) { - logger.V(logutil.DEFAULT).Info("Pod removed or not added", "name", namespacedName) + logger.V(logutil.DEBUG).Info("Pod removed or not added", "name", namespacedName) c.Datastore.PodDelete(namespacedName) } else { if c.Datastore.PodUpdateOrAddIfNotExist(pod) { diff --git a/pkg/epp/controller/pod_reconciler_test.go b/pkg/epp/controller/pod_reconciler_test.go index 8a39dbab..57576213 100644 --- a/pkg/epp/controller/pod_reconciler_test.go +++ b/pkg/epp/controller/pod_reconciler_test.go @@ -18,13 +18,11 @@ package controller import ( "context" - "sync" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -33,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + utiltest "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) var ( @@ -42,8 +41,7 @@ var ( basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11", ScrapePath: "/metrics", ScrapePort: 8000}} ) -func TestUpdateDatastore_PodReconciler(t *testing.T) { - now := metav1.Now() +func TestPodReconciler(t *testing.T) { tests := []struct { name string datastore datastore.Datastore @@ -53,7 +51,7 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }{ { name: "Add new pod", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -61,28 +59,15 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }), - incomingPod: &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: basePod3.NamespacedName.Name, - Labels: map[string]string{ - "some-key": "some-val", - }, - }, - Status: corev1.PodStatus{ - PodIP: basePod3.Address, - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionTrue, - }, - }, - }, - }, + incomingPod: utiltest.MakePod(basePod3.NamespacedName.Name). + Labels(map[string]string{"some-key": "some-val"}). + IP(basePod3.Address). + ReadyCondition().ObjRef(), wantPods: []datastore.Pod{basePod1.Pod, basePod2.Pod, basePod3.Pod}, }, { name: "Update pod1 address", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -90,28 +75,15 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }), - incomingPod: &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: basePod11.NamespacedName.Name, - Labels: map[string]string{ - "some-key": "some-val", - }, - }, - Status: corev1.PodStatus{ - PodIP: basePod11.Address, - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionTrue, - }, - }, - }, - }, + incomingPod: utiltest.MakePod(basePod11.NamespacedName.Name). + Labels(map[string]string{"some-key": "some-val"}). + IP(basePod11.Address). + ReadyCondition().ObjRef(), wantPods: []datastore.Pod{basePod11.Pod, basePod2.Pod}, }, { name: "Delete pod with DeletionTimestamp", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -119,29 +91,15 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }), - incomingPod: &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pod1", - Labels: map[string]string{ - "some-key": "some-val", - }, - DeletionTimestamp: &now, - Finalizers: []string{"finalizer"}, - }, - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionTrue, - }, - }, - }, - }, + incomingPod: utiltest.MakePod("pod1"). + Labels(map[string]string{"some-key": "some-val"}). + DeletionTimestamp(). + ReadyCondition().ObjRef(), wantPods: []datastore.Pod{basePod2.Pod}, }, { name: "Delete notfound pod", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -154,7 +112,7 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, { name: "New pod, not ready, valid selector", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -162,27 +120,13 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }), - incomingPod: &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pod3", - Labels: map[string]string{ - "some-key": "some-val", - }, - }, - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionFalse, - }, - }, - }, - }, + incomingPod: utiltest.MakePod("pod3"). + Labels(map[string]string{"some-key": "some-val"}).ObjRef(), wantPods: []datastore.Pod{basePod1.Pod, basePod2.Pod}, }, { name: "Remove pod that does not match selector", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -190,27 +134,14 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }), - incomingPod: &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pod1", - Labels: map[string]string{ - "some-wrong-key": "some-val", - }, - }, - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionTrue, - }, - }, - }, - }, + incomingPod: utiltest.MakePod("pod1"). + Labels(map[string]string{"some-wrong-key": "some-val"}). + ReadyCondition().ObjRef(), wantPods: []datastore.Pod{basePod2.Pod}, }, { name: "Remove pod that is not ready", - datastore: datastore.NewFakeDatastore(populateMap(basePod1, basePod2), nil, &v1alpha2.InferencePool{ + datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ @@ -218,22 +149,9 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }, }, }), - incomingPod: &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pod1", - Labels: map[string]string{ - "some-wrong-key": "some-val", - }, - }, - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionFalse, - }, - }, - }, - }, + incomingPod: utiltest.MakePod("pod1"). + Labels(map[string]string{"some-wrong-key": "some-val"}). + ReadyCondition().ObjRef(), wantPods: []datastore.Pod{basePod2.Pod}, }, } @@ -274,11 +192,3 @@ func TestUpdateDatastore_PodReconciler(t *testing.T) { }) } } - -func populateMap(pods ...*datastore.PodMetrics) *sync.Map { - newMap := &sync.Map{} - for _, pod := range pods { - newMap.Store(pod.NamespacedName, &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: pod.NamespacedName, Address: pod.Address, ScrapePort: pod.ScrapePort, ScrapePath: pod.ScrapePath}}) - } - return newMap -} diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index c5bbddcf..cd5d290f 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -19,6 +19,7 @@ package datastore import ( "context" "errors" + "fmt" "math/rand" "sync" @@ -32,6 +33,14 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +const ( + ModelNameIndexKey = "spec.modelName" +) + +var ( + errPoolNotSynced = errors.New("InferencePool is not initialized in data store") +) + // The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) type Datastore interface { // InferencePool operations @@ -41,9 +50,11 @@ type Datastore interface { PoolLabelsMatch(podLabels map[string]string) bool // InferenceModel operations - ModelSet(infModel *v1alpha2.InferenceModel) + ModelSetIfOlder(infModel *v1alpha2.InferenceModel) bool ModelGet(modelName string) (*v1alpha2.InferenceModel, bool) - ModelDelete(modelName string) + ModelDelete(namespacedName types.NamespacedName) (*v1alpha2.InferenceModel, bool) + ModelResync(ctx context.Context, ctrlClient client.Client, modelName string) (bool, error) + ModelGetAll() []*v1alpha2.InferenceModel // PodMetrics operations PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool @@ -61,22 +72,27 @@ type Datastore interface { func NewDatastore() Datastore { store := &datastore{ - poolMu: sync.RWMutex{}, - models: &sync.Map{}, - pods: &sync.Map{}, + poolAndModelsMu: sync.RWMutex{}, + models: make(map[string]*v1alpha2.InferenceModel), + pods: &sync.Map{}, } return store } // Used for test only -func NewFakeDatastore(pods, models *sync.Map, pool *v1alpha2.InferencePool) Datastore { +func NewFakeDatastore(pods []*PodMetrics, models []*v1alpha2.InferenceModel, pool *v1alpha2.InferencePool) Datastore { store := NewDatastore() - if pods != nil { - store.(*datastore).pods = pods + + for _, pod := range pods { + // Making a copy since in tests we may use the same global PodMetric across tests. + p := *pod + store.(*datastore).pods.Store(pod.NamespacedName, &p) } - if models != nil { - store.(*datastore).models = models + + for _, m := range models { + store.ModelSetIfOlder(m) } + if pool != nil { store.(*datastore).pool = pool } @@ -84,65 +100,132 @@ func NewFakeDatastore(pods, models *sync.Map, pool *v1alpha2.InferencePool) Data } type datastore struct { - // poolMu is used to synchronize access to the inferencePool. - poolMu sync.RWMutex - pool *v1alpha2.InferencePool - models *sync.Map + // poolAndModelsMu is used to synchronize access to pool and the models map. + poolAndModelsMu sync.RWMutex + pool *v1alpha2.InferencePool + // key: InferenceModel.Spec.ModelName, value: *InferenceModel + models map[string]*v1alpha2.InferenceModel // key: types.NamespacedName, value: *PodMetrics pods *sync.Map } func (ds *datastore) Clear() { - ds.poolMu.Lock() - defer ds.poolMu.Unlock() + ds.poolAndModelsMu.Lock() + defer ds.poolAndModelsMu.Unlock() ds.pool = nil - ds.models.Clear() + ds.models = make(map[string]*v1alpha2.InferenceModel) ds.pods.Clear() } // /// InferencePool APIs /// func (ds *datastore) PoolSet(pool *v1alpha2.InferencePool) { - ds.poolMu.Lock() - defer ds.poolMu.Unlock() + ds.poolAndModelsMu.Lock() + defer ds.poolAndModelsMu.Unlock() ds.pool = pool } func (ds *datastore) PoolGet() (*v1alpha2.InferencePool, error) { - ds.poolMu.RLock() - defer ds.poolMu.RUnlock() + ds.poolAndModelsMu.RLock() + defer ds.poolAndModelsMu.RUnlock() if !ds.PoolHasSynced() { - return nil, errors.New("InferencePool is not initialized in data store") + return nil, errPoolNotSynced } return ds.pool, nil } func (ds *datastore) PoolHasSynced() bool { - ds.poolMu.RLock() - defer ds.poolMu.RUnlock() + ds.poolAndModelsMu.RLock() + defer ds.poolAndModelsMu.RUnlock() return ds.pool != nil } func (ds *datastore) PoolLabelsMatch(podLabels map[string]string) bool { + ds.poolAndModelsMu.RLock() + defer ds.poolAndModelsMu.RUnlock() poolSelector := selectorFromInferencePoolSelector(ds.pool.Spec.Selector) podSet := labels.Set(podLabels) return poolSelector.Matches(podSet) } // /// InferenceModel APIs /// -func (ds *datastore) ModelSet(infModel *v1alpha2.InferenceModel) { - ds.models.Store(infModel.Spec.ModelName, infModel) +func (ds *datastore) ModelSetIfOlder(infModel *v1alpha2.InferenceModel) bool { + ds.poolAndModelsMu.Lock() + defer ds.poolAndModelsMu.Unlock() + + // Check first if the existing model is older. + // One exception is if the incoming model object is the same, in which case, we should not + // check for creation timestamp since that means the object was re-created, and so we should override. + existing, exists := ds.models[infModel.Spec.ModelName] + if exists { + diffObj := infModel.Name != existing.Name || infModel.Namespace != existing.Namespace + if diffObj && existing.ObjectMeta.CreationTimestamp.Before(&infModel.ObjectMeta.CreationTimestamp) { + return false + } + } + // Set the model. + ds.models[infModel.Spec.ModelName] = infModel + return true +} + +func (ds *datastore) ModelResync(ctx context.Context, c client.Client, modelName string) (bool, error) { + ds.poolAndModelsMu.Lock() + defer ds.poolAndModelsMu.Unlock() + + var models v1alpha2.InferenceModelList + if err := c.List(ctx, &models, client.MatchingFields{ModelNameIndexKey: modelName}, client.InNamespace(ds.pool.Namespace)); err != nil { + return false, fmt.Errorf("listing models that match the modelName %s: %w", modelName, err) + } + if len(models.Items) == 0 { + // No other instances of InferenceModels with this ModelName exists. + return false, nil + } + + var oldest *v1alpha2.InferenceModel + for i := range models.Items { + m := &models.Items[i] + if m.Spec.ModelName != modelName || // The index should filter those out, but just in case! + m.Spec.PoolRef.Name != ds.pool.Name || // We don't care about other pools, we could setup an index on this too! + !m.DeletionTimestamp.IsZero() { // ignore objects marked for deletion + continue + } + if oldest == nil || m.ObjectMeta.CreationTimestamp.Before(&oldest.ObjectMeta.CreationTimestamp) { + oldest = m + } + } + if oldest == nil { + return false, nil + } + ds.models[modelName] = oldest + return true, nil } func (ds *datastore) ModelGet(modelName string) (*v1alpha2.InferenceModel, bool) { - infModel, ok := ds.models.Load(modelName) - if ok { - return infModel.(*v1alpha2.InferenceModel), true + ds.poolAndModelsMu.RLock() + defer ds.poolAndModelsMu.RUnlock() + m, exists := ds.models[modelName] + return m, exists +} + +func (ds *datastore) ModelDelete(namespacedName types.NamespacedName) (*v1alpha2.InferenceModel, bool) { + ds.poolAndModelsMu.Lock() + defer ds.poolAndModelsMu.Unlock() + for _, m := range ds.models { + if m.Name == namespacedName.Name && m.Namespace == namespacedName.Namespace { + delete(ds.models, m.Spec.ModelName) + return m, true + } } return nil, false } -func (ds *datastore) ModelDelete(modelName string) { - ds.models.Delete(modelName) +func (ds *datastore) ModelGetAll() []*v1alpha2.InferenceModel { + ds.poolAndModelsMu.RLock() + defer ds.poolAndModelsMu.RUnlock() + res := []*v1alpha2.InferenceModel{} + for _, v := range ds.models { + res = append(res, v) + } + return res } // /// Pods/endpoints APIs /// diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index 2af36541..edc96626 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -19,45 +19,194 @@ package datastore import ( "testing" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + testutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) -func TestHasSynced(t *testing.T) { +func TestPool(t *testing.T) { + pool1Selector := map[string]string{"app": "vllm_v1"} + pool1 := testutil.MakeInferencePool("pool1"). + Namespace("default"). + Selector(pool1Selector).ObjRef() tests := []struct { - name string - inferencePool *v1alpha2.InferencePool - hasSynced bool + name string + inferencePool *v1alpha2.InferencePool + labels map[string]string + wantSynced bool + wantPool *v1alpha2.InferencePool + wantErr error + wantLabelsMatch bool }{ { - name: "Ready when InferencePool exists in data store", - inferencePool: &v1alpha2.InferencePool{ - ObjectMeta: v1.ObjectMeta{ - Name: "test-pool", - Namespace: "default", - }, - }, - hasSynced: true, + name: "Ready when InferencePool exists in data store", + inferencePool: pool1, + labels: pool1Selector, + wantSynced: true, + wantPool: pool1, + wantLabelsMatch: true, + }, + { + name: "Labels not matched", + inferencePool: pool1, + labels: map[string]string{"app": "vllm_v2"}, + wantSynced: true, + wantPool: pool1, + wantLabelsMatch: false, }, { - name: "Not ready when InferencePool is nil in data store", - inferencePool: nil, - hasSynced: false, + name: "Not ready when InferencePool is nil in data store", + wantErr: errPoolNotSynced, + wantSynced: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { datastore := NewDatastore() - // Set the inference pool - if tt.inferencePool != nil { - datastore.PoolSet(tt.inferencePool) + datastore.PoolSet(tt.inferencePool) + gotPool, gotErr := datastore.PoolGet() + if diff := cmp.Diff(tt.wantErr, gotErr, cmpopts.EquateErrors()); diff != "" { + t.Errorf("Unexpected error diff (+got/-want): %s", diff) + } + if diff := cmp.Diff(tt.wantPool, gotPool); diff != "" { + t.Errorf("Unexpected pool diff (+got/-want): %s", diff) + } + gotSynced := datastore.PoolHasSynced() + if diff := cmp.Diff(tt.wantSynced, gotSynced); diff != "" { + t.Errorf("Unexpected synced diff (+got/-want): %s", diff) + } + if tt.labels != nil { + gotLabelsMatch := datastore.PoolLabelsMatch(tt.labels) + if diff := cmp.Diff(tt.wantLabelsMatch, gotLabelsMatch); diff != "" { + t.Errorf("Unexpected labels match diff (+got/-want): %s", diff) + } + } + }) + } +} + +func TestModel(t *testing.T) { + chatModel := "chat" + tsModel := "tweet-summary" + model1ts := testutil.MakeInferenceModel("model1"). + CreationTimestamp(metav1.Unix(1000, 0)). + ModelName(tsModel).ObjRef() + // Same model name as model1ts, different object name. + model2ts := testutil.MakeInferenceModel("model2"). + CreationTimestamp(metav1.Unix(1001, 0)). + ModelName(tsModel).ObjRef() + // Same model name as model1ts, newer timestamp + model1tsNewer := testutil.MakeInferenceModel("model1"). + CreationTimestamp(metav1.Unix(1002, 0)). + Criticality(v1alpha2.Critical). + ModelName(tsModel).ObjRef() + model2tsNewer := testutil.MakeInferenceModel("model2"). + CreationTimestamp(metav1.Unix(1003, 0)). + ModelName(tsModel).ObjRef() + // Same object name as model2ts, different model name. + model2chat := testutil.MakeInferenceModel(model2ts.Name). + CreationTimestamp(metav1.Unix(1005, 0)). + ModelName(chatModel).ObjRef() + + tests := []struct { + name string + existingModels []*v1alpha2.InferenceModel + op func(ds Datastore) bool + wantOpResult bool + wantModels []*v1alpha2.InferenceModel + }{ + { + name: "Add model1 with tweet-summary as modelName", + op: func(ds Datastore) bool { + return ds.ModelSetIfOlder(model1ts) + }, + wantModels: []*v1alpha2.InferenceModel{model1ts}, + wantOpResult: true, + }, + { + name: "Set model1 with the same modelName, but with diff criticality and newer creation timestamp, should update.", + existingModels: []*v1alpha2.InferenceModel{model1ts}, + op: func(ds Datastore) bool { + return ds.ModelSetIfOlder(model1tsNewer) + }, + wantOpResult: true, + wantModels: []*v1alpha2.InferenceModel{model1tsNewer}, + }, + { + name: "set model2 with the same modelName, but newer creation timestamp, should not update.", + existingModels: []*v1alpha2.InferenceModel{model1tsNewer}, + op: func(ds Datastore) bool { + return ds.ModelSetIfOlder(model2tsNewer) + }, + wantOpResult: false, + wantModels: []*v1alpha2.InferenceModel{model1tsNewer}, + }, + { + name: "Set model2 with the same modelName, but older creation timestamp, should update", + existingModels: []*v1alpha2.InferenceModel{model1tsNewer}, + op: func(ds Datastore) bool { + return ds.ModelSetIfOlder(model2ts) + }, + wantOpResult: true, + wantModels: []*v1alpha2.InferenceModel{model2ts}, + }, + { + name: "Set model1 with the tweet-summary modelName, both models should exist", + existingModels: []*v1alpha2.InferenceModel{model2chat}, + op: func(ds Datastore) bool { + return ds.ModelSetIfOlder(model1ts) + }, + wantOpResult: true, + wantModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, + }, + { + name: "Set model1 with the tweet-summary modelName, both models should exist", + existingModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, + op: func(ds Datastore) bool { + return ds.ModelSetIfOlder(model1ts) + }, + wantOpResult: true, + wantModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, + }, + { + name: "Getting by model name, chat -> model2", + existingModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, + op: func(ds Datastore) bool { + gotChat, exists := ds.ModelGet(chatModel) + return exists && cmp.Diff(model2chat, gotChat) == "" + }, + wantOpResult: true, + wantModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, + }, + { + name: "Delete the model", + existingModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, + op: func(ds Datastore) bool { + _, existed := ds.ModelDelete(types.NamespacedName{Name: model1ts.Name, Namespace: model1ts.Namespace}) + _, exists := ds.ModelGet(tsModel) + return existed && !exists + + }, + wantOpResult: true, + wantModels: []*v1alpha2.InferenceModel{model2chat}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ds := NewFakeDatastore(nil, test.existingModels, nil) + gotOpResult := test.op(ds) + if gotOpResult != test.wantOpResult { + t.Errorf("Unexpected operation result, want: %v, got: %v", test.wantOpResult, gotOpResult) } - // Check if the data store has been initialized - hasSynced := datastore.PoolHasSynced() - if hasSynced != tt.hasSynced { - t.Errorf("IsInitialized() = %v, want %v", hasSynced, tt.hasSynced) + + if diff := testutil.DiffModelLists(test.wantModels, ds.ModelGetAll()); diff != "" { + t.Errorf("Unexpected models diff: %s", diff) } + }) } } diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 6e6b68b1..f3d9b6ac 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -85,7 +85,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { } // SetupWithManager sets up the runner with the given manager. -func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { +func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { // Create the controllers and register them with the manager if err := (&controller.InferencePoolReconciler{ Datastore: r.Datastore, @@ -109,7 +109,7 @@ func (r *ExtProcServerRunner) SetupWithManager(mgr ctrl.Manager) error { Namespace: r.PoolNamespace, }, Record: mgr.GetEventRecorderFor("InferenceModel"), - }).SetupWithManager(mgr); err != nil { + }).SetupWithManager(ctx, mgr); err != nil { return fmt.Errorf("failed setting up InferenceModelReconciler: %w", err) } diff --git a/pkg/epp/test/utils.go b/pkg/epp/test/utils.go index 6a75ed2f..a916bda2 100644 --- a/pkg/epp/test/utils.go +++ b/pkg/epp/test/utils.go @@ -53,14 +53,15 @@ func StartExtProc( pmc := &backend.FakePodMetricsClient{Res: pms} datastore := datastore.NewDatastore() for _, m := range models { - datastore.ModelSet(m) + datastore.ModelSetIfOlder(m) } for _, pm := range pods { - pod := utiltesting.MakePod(pm.NamespacedName.Name, pm.NamespacedName.Namespace). + pod := utiltesting.MakePod(pm.NamespacedName.Name). + Namespace(pm.NamespacedName.Namespace). ReadyCondition(). IP(pm.Address). - Obj() - datastore.PodUpdateOrAddIfNotExist(&pod) + ObjRef() + datastore.PodUpdateOrAddIfNotExist(pod) datastore.PodUpdateMetricsIfExist(pm.NamespacedName, &pm.Metrics) } pp := backend.NewProvider(pmc, datastore) diff --git a/pkg/epp/util/testing/diff.go b/pkg/epp/util/testing/diff.go new file mode 100644 index 00000000..34b0b8ca --- /dev/null +++ b/pkg/epp/util/testing/diff.go @@ -0,0 +1,27 @@ +/* +Copyright 2025 The Kubernetes 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 testing + +import ( + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +func DiffModelLists(want, got []*v1alpha2.InferenceModel) string { + return cmp.Diff(want, got, cmpopts.SortSlices(func(a, b *v1alpha2.InferenceModel) bool { return a.Name < b.Name })) +} diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go index 7c9a2939..bfcf2690 100644 --- a/pkg/epp/util/testing/wrappers.go +++ b/pkg/epp/util/testing/wrappers.go @@ -19,6 +19,7 @@ package testing import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) // PodWrapper wraps a Pod. @@ -27,12 +28,11 @@ type PodWrapper struct { } // MakePod creates a wrapper for a Pod. -func MakePod(podName, ns string) *PodWrapper { +func MakePod(podName string) *PodWrapper { return &PodWrapper{ corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: podName, - Namespace: ns, + Name: podName, }, Spec: corev1.PodSpec{}, Status: corev1.PodStatus{}, @@ -40,6 +40,11 @@ func MakePod(podName, ns string) *PodWrapper { } } +func (p *PodWrapper) Namespace(ns string) *PodWrapper { + p.ObjectMeta.Namespace = ns + return p +} + // Labels sets the pod labels. func (p *PodWrapper) Labels(labels map[string]string) *PodWrapper { p.ObjectMeta.Labels = labels @@ -60,7 +65,109 @@ func (p *PodWrapper) IP(ip string) *PodWrapper { return p } +func (p *PodWrapper) DeletionTimestamp() *PodWrapper { + now := metav1.Now() + p.ObjectMeta.DeletionTimestamp = &now + p.ObjectMeta.Finalizers = []string{"finalizer"} + return p +} + // Obj returns the wrapped Pod. -func (p *PodWrapper) Obj() corev1.Pod { - return p.Pod +func (p *PodWrapper) ObjRef() *corev1.Pod { + return &p.Pod +} + +// InferenceModelWrapper wraps an InferenceModel. +type InferenceModelWrapper struct { + v1alpha2.InferenceModel +} + +// MakeInferenceModel creates a wrapper for a InferenceModel. +func MakeInferenceModel(name string) *InferenceModelWrapper { + return &InferenceModelWrapper{ + v1alpha2.InferenceModel{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha2.InferenceModelSpec{}, + }, + } +} + +func (m *InferenceModelWrapper) Namespace(ns string) *InferenceModelWrapper { + m.ObjectMeta.Namespace = ns + return m +} + +// Obj returns the wrapped InferenceModel. +func (m *InferenceModelWrapper) ObjRef() *v1alpha2.InferenceModel { + return &m.InferenceModel +} + +func (m *InferenceModelWrapper) ModelName(modelName string) *InferenceModelWrapper { + m.Spec.ModelName = modelName + return m +} + +func (m *InferenceModelWrapper) PoolName(poolName string) *InferenceModelWrapper { + m.Spec.PoolRef = v1alpha2.PoolObjectReference{Name: poolName} + return m +} + +func (m *InferenceModelWrapper) Criticality(criticality v1alpha2.Criticality) *InferenceModelWrapper { + m.Spec.Criticality = &criticality + return m +} + +func (m *InferenceModelWrapper) DeletionTimestamp() *InferenceModelWrapper { + now := metav1.Now() + m.ObjectMeta.DeletionTimestamp = &now + m.ObjectMeta.Finalizers = []string{"finalizer"} + return m +} + +func (m *InferenceModelWrapper) CreationTimestamp(t metav1.Time) *InferenceModelWrapper { + m.ObjectMeta.CreationTimestamp = t + return m +} + +// InferencePoolWrapper wraps an InferencePool. +type InferencePoolWrapper struct { + v1alpha2.InferencePool +} + +// MakeInferencePool creates a wrapper for a InferencePool. +func MakeInferencePool(name string) *InferencePoolWrapper { + return &InferencePoolWrapper{ + v1alpha2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha2.InferencePoolSpec{}, + }, + } +} + +func (m *InferencePoolWrapper) Namespace(ns string) *InferencePoolWrapper { + m.ObjectMeta.Namespace = ns + return m +} + +func (m *InferencePoolWrapper) Selector(selector map[string]string) *InferencePoolWrapper { + s := make(map[v1alpha2.LabelKey]v1alpha2.LabelValue) + for k, v := range selector { + s[v1alpha2.LabelKey(k)] = v1alpha2.LabelValue(v) + } + m.Spec.Selector = s + return m +} + +func (m *InferencePoolWrapper) TargetPortNumber(p int32) *InferencePoolWrapper { + m.Spec.TargetPortNumber = p + return m +} + +// Obj returns the wrapped InferencePool. +func (m *InferencePoolWrapper) ObjRef() *v1alpha2.InferencePool { + return &m.InferencePool } diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 14ee738f..3d068c9f 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -245,11 +245,6 @@ func createModelServer(k8sClient client.Client, secretPath, deployPath string) { // Wait for the deployment to be available. testutils.DeploymentAvailable(ctx, k8sClient, deploy, modelReadyTimeout, interval) - - // Wait for the service to exist. - testutils.EventuallyExists(ctx, func() error { - return k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: modelServerName}, &corev1.Service{}) - }, existsTimeout, interval) } // createEnvoy creates the envoy proxy resources used for testing from the given filePath. diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 85c49913..2ea66dba 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -360,11 +360,12 @@ func setUpHermeticServer(podMetrics []*datastore.PodMetrics) (client extProcPb.E go func() { serverRunner.Datastore.PodDeleteAll() for _, pm := range podMetrics { - pod := utiltesting.MakePod(pm.NamespacedName.Name, pm.NamespacedName.Namespace). + pod := utiltesting.MakePod(pm.NamespacedName.Name). + Namespace(pm.NamespacedName.Namespace). ReadyCondition(). IP(pm.Address). - Obj() - serverRunner.Datastore.PodUpdateOrAddIfNotExist(&pod) + ObjRef() + serverRunner.Datastore.PodUpdateOrAddIfNotExist(pod) serverRunner.Datastore.PodUpdateMetricsIfExist(pm.NamespacedName, &pm.Metrics) } serverRunner.Provider = backend.NewProvider(pmc, serverRunner.Datastore) @@ -429,7 +430,7 @@ func BeforeSuit(t *testing.T) func() { serverRunner.Datastore = datastore.NewDatastore() serverRunner.SecureServing = false - if err := serverRunner.SetupWithManager(mgr); err != nil { + if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { logutil.Fatal(logger, err, "Failed to setup server runner") } From b9bbc2e1864521cfe62fe09c924638e60b79a6ab Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 27 Feb 2025 20:02:31 +0000 Subject: [PATCH 057/260] Updated yamls to use v1alpha2 (#420) --- config/manifests/ext_proc.yaml | 2 +- config/manifests/inferencemodel.yaml | 2 +- test/testdata/inferencepool-with-model-hermetic.yaml | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/manifests/ext_proc.yaml b/config/manifests/ext_proc.yaml index 49145d24..f96113e1 100644 --- a/config/manifests/ext_proc.yaml +++ b/config/manifests/ext_proc.yaml @@ -40,7 +40,7 @@ roleRef: kind: ClusterRole name: pod-read --- -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: labels: diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 2a292c16..57240298 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -1,4 +1,4 @@ -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: inferencemodel-sample diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml index 372a8512..c9ca763e 100644 --- a/test/testdata/inferencepool-with-model-hermetic.yaml +++ b/test/testdata/inferencepool-with-model-hermetic.yaml @@ -1,4 +1,4 @@ -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: name: vllm-llama2-7b-pool @@ -10,7 +10,7 @@ spec: extensionRef: name: epp --- -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: inferencemodel-sample @@ -24,7 +24,7 @@ spec: - name: sql-lora-1fdg2 weight: 100 --- -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: inferencemodel-sheddable @@ -37,7 +37,7 @@ spec: - name: sql-lora-1fdg3 weight: 100 --- -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: inferencemodel-generic From 10133bafe4e28368b2df52ff2d59ee151b38ca68 Mon Sep 17 00:00:00 2001 From: Tiger Xu / Zhonghu Xu Date: Fri, 28 Feb 2025 04:14:31 +0800 Subject: [PATCH 058/260] Rm v1alpha1 api (#405) * remove v1alpha1 * auto gen * Add document to disallow cross namespace match explicitly --- api/v1alpha1/doc.go | 23 -- api/v1alpha1/groupversion_info.go | 45 --- api/v1alpha1/inferencemodel_types.go | 234 ------------ api/v1alpha1/inferencepool_types.go | 238 ------------ api/v1alpha1/zz_generated.deepcopy.go | 361 ------------------ api/v1alpha2/inferencepool_types.go | 2 + .../api/v1alpha1/endpointpickerconfig.go | 38 -- .../api/v1alpha1/extension.go | 75 ---- .../api/v1alpha1/extensionconnection.go | 42 -- .../api/v1alpha1/extensionreference.go | 65 ---- .../api/v1alpha1/inferencemodel.go | 224 ----------- .../api/v1alpha1/inferencemodelspec.go | 74 ---- .../api/v1alpha1/inferencemodelstatus.go | 47 --- .../api/v1alpha1/inferencepool.go | 224 ----------- .../api/v1alpha1/inferencepoolspec.go | 66 ---- .../api/v1alpha1/inferencepoolstatus.go | 47 --- .../api/v1alpha1/poolobjectreference.go | 56 --- .../api/v1alpha1/targetmodel.go | 47 --- client-go/applyconfiguration/utils.go | 30 +- client-go/clientset/versioned/clientset.go | 13 - .../versioned/fake/clientset_generated.go | 7 - .../clientset/versioned/fake/register.go | 2 - .../clientset/versioned/scheme/register.go | 2 - .../typed/api/v1alpha1/api_client.go | 111 ------ .../versioned/typed/api/v1alpha1/doc.go | 19 - .../versioned/typed/api/v1alpha1/fake/doc.go | 19 - .../api/v1alpha1/fake/fake_api_client.go | 43 --- .../api/v1alpha1/fake/fake_inferencemodel.go | 52 --- .../api/v1alpha1/fake/fake_inferencepool.go | 52 --- .../typed/api/v1alpha1/generated_expansion.go | 22 -- .../typed/api/v1alpha1/inferencemodel.go | 73 ---- .../typed/api/v1alpha1/inferencepool.go | 73 ---- .../externalversions/api/interface.go | 8 - .../api/v1alpha1/inferencemodel.go | 89 ----- .../api/v1alpha1/inferencepool.go | 89 ----- .../api/v1alpha1/interface.go | 51 --- .../informers/externalversions/generic.go | 9 +- .../api/v1alpha1/expansion_generated.go | 34 -- .../listers/api/v1alpha1/inferencemodel.go | 69 ---- .../listers/api/v1alpha1/inferencepool.go | 69 ---- cmd/epp/main.go | 3 - ...e.networking.x-k8s.io_inferencemodels.yaml | 224 ----------- ...ce.networking.x-k8s.io_inferencepools.yaml | 192 +--------- 43 files changed, 6 insertions(+), 3257 deletions(-) delete mode 100644 api/v1alpha1/doc.go delete mode 100644 api/v1alpha1/groupversion_info.go delete mode 100644 api/v1alpha1/inferencemodel_types.go delete mode 100644 api/v1alpha1/inferencepool_types.go delete mode 100644 api/v1alpha1/zz_generated.deepcopy.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/endpointpickerconfig.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/extension.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/extensionconnection.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/extensionreference.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/inferencemodel.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/inferencemodelstatus.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/inferencepool.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/inferencepoolstatus.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/poolobjectreference.go delete mode 100644 client-go/applyconfiguration/api/v1alpha1/targetmodel.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/api_client.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/doc.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/fake/doc.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/generated_expansion.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go delete mode 100644 client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go delete mode 100644 client-go/informers/externalversions/api/v1alpha1/inferencemodel.go delete mode 100644 client-go/informers/externalversions/api/v1alpha1/inferencepool.go delete mode 100644 client-go/informers/externalversions/api/v1alpha1/interface.go delete mode 100644 client-go/listers/api/v1alpha1/expansion_generated.go delete mode 100644 client-go/listers/api/v1alpha1/inferencemodel.go delete mode 100644 client-go/listers/api/v1alpha1/inferencepool.go diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go deleted file mode 100644 index 8e970ced..00000000 --- a/api/v1alpha1/doc.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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 v1alpha1 contains API Schema definitions for the -// inference.networking.x-k8s.io API group. -// -// +k8s:openapi-gen=true -// +kubebuilder:object:generate=true -// +groupName=inference.networking.x-k8s.io -package v1alpha1 diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go deleted file mode 100644 index 8c0a449f..00000000 --- a/api/v1alpha1/groupversion_info.go +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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 v1alpha1 contains API Schema definitions for the gateway v1alpha1 API group -// +kubebuilder:object:generate=true -// +groupName=inference.networking.x-k8s.io -package v1alpha1 - -import ( - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" -) - -var ( - // GroupVersion is group version used to register these objects - GroupVersion = schema.GroupVersion{Group: "inference.networking.x-k8s.io", Version: "v1alpha1"} - - // SchemeGroupVersion is alias to GroupVersion for client-go libraries. - // It is required by pkg/client/informers/externalversions/... - SchemeGroupVersion = GroupVersion - - // SchemeBuilder is used to add go types to the GroupVersionKind scheme - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} - - // AddToScheme adds the types in this group-version to the given scheme. - AddToScheme = SchemeBuilder.AddToScheme -) - -// Resource is required by pkg/client/listers/... -func Resource(resource string) schema.GroupResource { - return GroupVersion.WithResource(resource).GroupResource() -} diff --git a/api/v1alpha1/inferencemodel_types.go b/api/v1alpha1/inferencemodel_types.go deleted file mode 100644 index f171c10e..00000000 --- a/api/v1alpha1/inferencemodel_types.go +++ /dev/null @@ -1,234 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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 v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// InferenceModel is the Schema for the InferenceModels API. -// -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +genclient -type InferenceModel struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec InferenceModelSpec `json:"spec,omitempty"` - Status InferenceModelStatus `json:"status,omitempty"` -} - -// InferenceModelList contains a list of InferenceModel. -// -// +kubebuilder:object:root=true -type InferenceModelList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []InferenceModel `json:"items"` -} - -// InferenceModelSpec represents the desired state of a specific model use case. This resource is -// managed by the "Inference Workload Owner" persona. -// -// The Inference Workload Owner persona is someone that trains, verifies, and -// leverages a large language model from a model frontend, drives the lifecycle -// and rollout of new versions of those models, and defines the specific -// performance and latency goals for the model. These workloads are -// expected to operate within an InferencePool sharing compute capacity with other -// InferenceModels, defined by the Inference Platform Admin. -// -// InferenceModel's modelName (not the ObjectMeta name) is unique for a given InferencePool, -// if the name is reused, an error will be shown on the status of a -// InferenceModel that attempted to reuse. The oldest InferenceModel, based on -// creation timestamp, will be selected to remain valid. In the event of a race -// condition, one will be selected at random. -type InferenceModelSpec struct { - // ModelName is the name of the model as it will be set in the "model" parameter for an incoming request. - // ModelNames must be unique for a referencing InferencePool - // (names can be reused for a different pool in the same cluster). - // The modelName with the oldest creation timestamp is retained, and the incoming - // InferenceModel is sets the Ready status to false with a corresponding reason. - // In the rare case of a race condition, one Model will be selected randomly to be considered valid, and the other rejected. - // Names can be reserved without an underlying model configured in the pool. - // This can be done by specifying a target model and setting the weight to zero, - // an error will be returned specifying that no valid target model is found. - // - // +kubebuilder:validation:MaxLength=256 - // +kubebuilder:validation:Required - ModelName string `json:"modelName"` - - // Criticality defines how important it is to serve the model compared to other models referencing the same pool. - // Criticality impacts how traffic is handled in resource constrained situations. It handles this by - // queuing or rejecting requests of lower criticality. InferenceModels of an equivalent Criticality will - // fairly share resources over throughput of tokens. In the future, the metric used to calculate fairness, - // and the proportionality of fairness will be configurable. - // - // Default values for this field will not be set, to allow for future additions of new field that may 'one of' with this field. - // Any implementations that may consume this field may treat an unset value as the 'Standard' range. - // +optional - Criticality *Criticality `json:"criticality,omitempty"` - - // TargetModels allow multiple versions of a model for traffic splitting. - // If not specified, the target model name is defaulted to the modelName parameter. - // modelName is often in reference to a LoRA adapter. - // - // +optional - // +kubebuilder:validation:MaxItems=10 - // +kubebuilder:validation:XValidation:message="Weights should be set for all models, or none of the models.",rule="self.all(model, has(model.weight)) || self.all(model, !has(model.weight))" - TargetModels []TargetModel `json:"targetModels,omitempty"` - - // PoolRef is a reference to the inference pool, the pool must exist in the same namespace. - // - // +kubebuilder:validation:Required - PoolRef PoolObjectReference `json:"poolRef"` -} - -// PoolObjectReference identifies an API object within the namespace of the -// referrer. -type PoolObjectReference struct { - // Group is the group of the referent. - // - // +optional - // +kubebuilder:default="inference.networking.x-k8s.io" - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` - Group string `json:"group,omitempty"` - - // Kind is kind of the referent. For example "InferencePool". - // - // +optional - // +kubebuilder:default="InferencePool" - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=63 - // +kubebuilder:validation:Pattern=`^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` - Kind string `json:"kind,omitempty"` - - // Name is the name of the referent. - // - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Required - Name string `json:"name"` -} - -// Criticality defines how important it is to serve the model compared to other models. -// Criticality is intentionally a bounded enum to contain the possibilities that need to be supported by the load balancing algorithm. Any reference to the Criticality field must be optional(use a pointer), and set no default. -// This allows us to union this with a oneOf field in the future should we wish to adjust/extend this behavior. -// +kubebuilder:validation:Enum=Critical;Standard;Sheddable -type Criticality string - -const ( - // Critical defines the highest level of criticality. Requests to this band will be shed last. - Critical Criticality = "Critical" - - // Standard defines the base criticality level and is more important than Sheddable but less - // important than Critical. Requests in this band will be shed before critical traffic. - // Most models are expected to fall within this band. - Standard Criticality = "Standard" - - // Sheddable defines the lowest level of criticality. Requests to this band will be shed before - // all other bands. - Sheddable Criticality = "Sheddable" -) - -// TargetModel represents a deployed model or a LoRA adapter. The -// Name field is expected to match the name of the LoRA adapter -// (or base model) as it is registered within the model server. Inference -// Gateway assumes that the model exists on the model server and it's the -// responsibility of the user to validate a correct match. Should a model fail -// to exist at request time, the error is processed by the Inference Gateway -// and emitted on the appropriate InferenceModel object. -type TargetModel struct { - // Name is the name of the adapter or base model, as expected by the ModelServer. - // - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Required - Name string `json:"name"` - - // Weight is used to determine the proportion of traffic that should be - // sent to this model when multiple target models are specified. - // - // Weight defines the proportion of requests forwarded to the specified - // model. This is computed as weight/(sum of all weights in this - // TargetModels list). For non-zero values, there may be some epsilon from - // the exact proportion defined here depending on the precision an - // implementation supports. Weight is not a percentage and the sum of - // weights does not need to equal 100. - // - // If a weight is set for any targetModel, it must be set for all targetModels. - // Conversely weights are optional, so long as ALL targetModels do not specify a weight. - // - // +optional - // +kubebuilder:validation:Minimum=0 - // +kubebuilder:validation:Maximum=1000000 - Weight *int32 `json:"weight,omitempty"` -} - -// InferenceModelStatus defines the observed state of InferenceModel -type InferenceModelStatus struct { - // Conditions track the state of the InferenceModel. - // - // Known condition types are: - // - // * "Accepted" - // - // +optional - // +listType=map - // +listMapKey=type - // +kubebuilder:validation:MaxItems=8 - // +kubebuilder:default={{type: "Ready", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}} - Conditions []metav1.Condition `json:"conditions,omitempty"` -} - -// InferenceModelConditionType is a type of condition for the InferenceModel. -type InferenceModelConditionType string - -// InferenceModelConditionReason is the reason for a given InferenceModelConditionType. -type InferenceModelConditionReason string - -const ( - // ModelConditionAccepted indicates if the model config is accepted, and if not, why. - // - // Possible reasons for this condition to be True are: - // - // * "Accepted" - // - // Possible reasons for this condition to be False are: - // - // * "ModelNameInUse" - // - // Possible reasons for this condition to be Unknown are: - // - // * "Pending" - // - ModelConditionAccepted InferenceModelConditionType = "Accepted" - - // ModelReasonAccepted is the desired state. Model conforms to the state of the pool. - ModelReasonAccepted InferenceModelConditionReason = "Accepted" - - // ModelReasonNameInUse is used when a given ModelName already exists within the pool. - // Details about naming conflict resolution are on the ModelName field itself. - ModelReasonNameInUse InferenceModelConditionReason = "ModelNameInUse" - - // ModelReasonPending is the initial state, and indicates that the controller has not yet reconciled the InferenceModel. - ModelReasonPending InferenceModelConditionReason = "Pending" -) - -func init() { - SchemeBuilder.Register(&InferenceModel{}, &InferenceModelList{}) -} diff --git a/api/v1alpha1/inferencepool_types.go b/api/v1alpha1/inferencepool_types.go deleted file mode 100644 index b4c95d40..00000000 --- a/api/v1alpha1/inferencepool_types.go +++ /dev/null @@ -1,238 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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 v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// InferencePool is the Schema for the InferencePools API. -// -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status -// +genclient -type InferencePool struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec InferencePoolSpec `json:"spec,omitempty"` - Status InferencePoolStatus `json:"status,omitempty"` -} - -// InferencePoolList contains a list of InferencePool. -// -// +kubebuilder:object:root=true -type InferencePoolList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []InferencePool `json:"items"` -} - -// InferencePoolSpec defines the desired state of InferencePool -type InferencePoolSpec struct { - // Selector defines a map of labels to watch model server pods - // that should be included in the InferencePool. - // In some cases, implementations may translate this field to a Service selector, so this matches the simple - // map used for Service selectors instead of the full Kubernetes LabelSelector type. - // - // +kubebuilder:validation:Required - Selector map[LabelKey]LabelValue `json:"selector"` - - // TargetPortNumber defines the port number to access the selected model servers. - // The number must be in the range 1 to 65535. - // - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:validation:Maximum=65535 - // +kubebuilder:validation:Required - TargetPortNumber int32 `json:"targetPortNumber"` - - // EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint - // picker service that picks endpoints for the requests routed to this pool. - EndpointPickerConfig `json:",inline"` -} - -// EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint picker extension. -// This type is intended to be a union of mutually exclusive configuration options that we may add in the future. -type EndpointPickerConfig struct { - // Extension configures an endpoint picker as an extension service. - // - // +kubebuilder:validation:Required - ExtensionRef *Extension `json:"extensionRef,omitempty"` -} - -// Extension specifies how to configure an extension that runs the endpoint picker. -type Extension struct { - // Reference is a reference to a service extension. - ExtensionReference `json:",inline"` - - // ExtensionConnection configures the connection between the gateway and the extension. - ExtensionConnection `json:",inline"` -} - -// ExtensionReference is a reference to the extension deployment. -type ExtensionReference struct { - // Group is the group of the referent. - // When unspecified or empty string, core API group is inferred. - // - // +optional - // +kubebuilder:default="" - Group *string `json:"group,omitempty"` - - // Kind is the Kubernetes resource kind of the referent. For example - // "Service". - // - // Defaults to "Service" when not specified. - // - // ExternalName services can refer to CNAME DNS records that may live - // outside of the cluster and as such are difficult to reason about in - // terms of conformance. They also may not be safe to forward to (see - // CVE-2021-25740 for more information). Implementations MUST NOT - // support ExternalName Services. - // - // +optional - // +kubebuilder:default=Service - Kind *string `json:"kind,omitempty"` - - // Name is the name of the referent. - // - // +kubebuilder:validation:Required - Name string `json:"name"` - - // The port number on the pods running the extension. When unspecified, implementations SHOULD infer a - // default value of 9002 when the Kind is Service. - // - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:validation:Maximum=65535 - // +optional - TargetPortNumber *int32 `json:"targetPortNumber,omitempty"` -} - -// ExtensionConnection encapsulates options that configures the connection to the extension. -type ExtensionConnection struct { - // Configures how the gateway handles the case when the extension is not responsive. - // Defaults to failClose. - // - // +optional - // +kubebuilder:default="FailClose" - FailureMode *ExtensionFailureMode `json:"failureMode"` -} - -// ExtensionFailureMode defines the options for how the gateway handles the case when the extension is not -// responsive. -// +kubebuilder:validation:Enum=FailOpen;FailClose -type ExtensionFailureMode string - -const ( - // FailOpen specifies that the proxy should not drop the request and forward the request to and endpoint of its picking. - FailOpen ExtensionFailureMode = "FailOpen" - // FailClose specifies that the proxy should drop the request. - FailClose ExtensionFailureMode = "FailClose" -) - -// LabelKey was originally copied from: https://github.com/kubernetes-sigs/gateway-api/blob/99a3934c6bc1ce0874f3a4c5f20cafd8977ffcb4/apis/v1/shared_types.go#L694-L731 -// Duplicated as to not take an unexpected dependency on gw's API. -// -// LabelKey is the key of a label. This is used for validation -// of maps. This matches the Kubernetes "qualified name" validation that is used for labels. -// Labels are case sensitive, so: my-label and My-Label are considered distinct. -// -// Valid values include: -// -// * example -// * example.com -// * example.com/path -// * example.com/path.html -// -// Invalid values include: -// -// * example~ - "~" is an invalid character -// * example.com. - can not start or end with "." -// -// +kubebuilder:validation:MinLength=1 -// +kubebuilder:validation:MaxLength=253 -// +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$` -type LabelKey string - -// LabelValue is the value of a label. This is used for validation -// of maps. This matches the Kubernetes label validation rules: -// * must be 63 characters or less (can be empty), -// * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), -// * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. -// -// Valid values include: -// -// * MyValue -// * my.name -// * 123-my-value -// -// +kubebuilder:validation:MinLength=0 -// +kubebuilder:validation:MaxLength=63 -// +kubebuilder:validation:Pattern=`^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$` -type LabelValue string - -// InferencePoolStatus defines the observed state of InferencePool -type InferencePoolStatus struct { - // Conditions track the state of the InferencePool. - // - // Known condition types are: - // - // * "Ready" - // - // +optional - // +listType=map - // +listMapKey=type - // +kubebuilder:validation:MaxItems=8 - // +kubebuilder:default={{type: "Ready", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}} - Conditions []metav1.Condition `json:"conditions,omitempty"` -} - -// InferencePoolConditionType is a type of condition for the InferencePool -type InferencePoolConditionType string - -// InferencePoolConditionReason is the reason for a given InferencePoolConditionType -type InferencePoolConditionReason string - -const ( - // PoolConditionReady indicates if the pool is ready to accept traffic, and if not, why. - // - // Possible reasons for this condition to be True are: - // - // * "Ready" - // - // Possible reasons for this condition to be False are: - // - // * "EndpointPickerNotHealthy" - // - // Possible reasons for this condition to be Unknown are: - // - // * "Pending" - // - PoolConditionReady InferencePoolConditionType = "Ready" - - // PoolReasonReady is the desired state. The pool and its components are initialized and ready for traffic. - PoolReasonReady InferencePoolConditionReason = "Ready" - - // PoolReasonEPPNotHealthy is used when the EPP has not yet passed health checks, or has started failing them. - PoolReasonEPPNotHealthy InferencePoolConditionReason = "EndpointPickerNotHealthy" - - // PoolReasonPending is the initial state, and indicates that the controller has not yet reconciled this pool. - PoolReasonPending InferencePoolConditionReason = "Pending" -) - -func init() { - SchemeBuilder.Register(&InferencePool{}, &InferencePoolList{}) -} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go deleted file mode 100644 index fd55379e..00000000 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ /dev/null @@ -1,361 +0,0 @@ -//go:build !ignore_autogenerated - -/* -Copyright 2024 The Kubernetes 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. -*/ - -// Code generated by controller-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" -) - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EndpointPickerConfig) DeepCopyInto(out *EndpointPickerConfig) { - *out = *in - if in.ExtensionRef != nil { - in, out := &in.ExtensionRef, &out.ExtensionRef - *out = new(Extension) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointPickerConfig. -func (in *EndpointPickerConfig) DeepCopy() *EndpointPickerConfig { - if in == nil { - return nil - } - out := new(EndpointPickerConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Extension) DeepCopyInto(out *Extension) { - *out = *in - in.ExtensionReference.DeepCopyInto(&out.ExtensionReference) - in.ExtensionConnection.DeepCopyInto(&out.ExtensionConnection) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Extension. -func (in *Extension) DeepCopy() *Extension { - if in == nil { - return nil - } - out := new(Extension) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExtensionConnection) DeepCopyInto(out *ExtensionConnection) { - *out = *in - if in.FailureMode != nil { - in, out := &in.FailureMode, &out.FailureMode - *out = new(ExtensionFailureMode) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtensionConnection. -func (in *ExtensionConnection) DeepCopy() *ExtensionConnection { - if in == nil { - return nil - } - out := new(ExtensionConnection) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExtensionReference) DeepCopyInto(out *ExtensionReference) { - *out = *in - if in.Group != nil { - in, out := &in.Group, &out.Group - *out = new(string) - **out = **in - } - if in.Kind != nil { - in, out := &in.Kind, &out.Kind - *out = new(string) - **out = **in - } - if in.TargetPortNumber != nil { - in, out := &in.TargetPortNumber, &out.TargetPortNumber - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtensionReference. -func (in *ExtensionReference) DeepCopy() *ExtensionReference { - if in == nil { - return nil - } - out := new(ExtensionReference) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferenceModel) DeepCopyInto(out *InferenceModel) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModel. -func (in *InferenceModel) DeepCopy() *InferenceModel { - if in == nil { - return nil - } - out := new(InferenceModel) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *InferenceModel) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferenceModelList) DeepCopyInto(out *InferenceModelList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]InferenceModel, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModelList. -func (in *InferenceModelList) DeepCopy() *InferenceModelList { - if in == nil { - return nil - } - out := new(InferenceModelList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *InferenceModelList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferenceModelSpec) DeepCopyInto(out *InferenceModelSpec) { - *out = *in - if in.Criticality != nil { - in, out := &in.Criticality, &out.Criticality - *out = new(Criticality) - **out = **in - } - if in.TargetModels != nil { - in, out := &in.TargetModels, &out.TargetModels - *out = make([]TargetModel, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - out.PoolRef = in.PoolRef -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModelSpec. -func (in *InferenceModelSpec) DeepCopy() *InferenceModelSpec { - if in == nil { - return nil - } - out := new(InferenceModelSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferenceModelStatus) DeepCopyInto(out *InferenceModelStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferenceModelStatus. -func (in *InferenceModelStatus) DeepCopy() *InferenceModelStatus { - if in == nil { - return nil - } - out := new(InferenceModelStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferencePool) DeepCopyInto(out *InferencePool) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePool. -func (in *InferencePool) DeepCopy() *InferencePool { - if in == nil { - return nil - } - out := new(InferencePool) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *InferencePool) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferencePoolList) DeepCopyInto(out *InferencePoolList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]InferencePool, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePoolList. -func (in *InferencePoolList) DeepCopy() *InferencePoolList { - if in == nil { - return nil - } - out := new(InferencePoolList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *InferencePoolList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferencePoolSpec) DeepCopyInto(out *InferencePoolSpec) { - *out = *in - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = make(map[LabelKey]LabelValue, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - in.EndpointPickerConfig.DeepCopyInto(&out.EndpointPickerConfig) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePoolSpec. -func (in *InferencePoolSpec) DeepCopy() *InferencePoolSpec { - if in == nil { - return nil - } - out := new(InferencePoolSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *InferencePoolStatus) DeepCopyInto(out *InferencePoolStatus) { - *out = *in - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InferencePoolStatus. -func (in *InferencePoolStatus) DeepCopy() *InferencePoolStatus { - if in == nil { - return nil - } - out := new(InferencePoolStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PoolObjectReference) DeepCopyInto(out *PoolObjectReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PoolObjectReference. -func (in *PoolObjectReference) DeepCopy() *PoolObjectReference { - if in == nil { - return nil - } - out := new(PoolObjectReference) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TargetModel) DeepCopyInto(out *TargetModel) { - *out = *in - if in.Weight != nil { - in, out := &in.Weight, &out.Weight - *out = new(int32) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TargetModel. -func (in *TargetModel) DeepCopy() *TargetModel { - if in == nil { - return nil - } - out := new(TargetModel) - in.DeepCopyInto(out) - return out -} diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index 716bfb11..0781f044 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -50,6 +50,8 @@ type InferencePoolSpec struct { // that should be included in the InferencePool. // In some cases, implementations may translate this field to a Service selector, so this matches the simple // map used for Service selectors instead of the full Kubernetes LabelSelector type. + // If sepecified, it will be applied to match the model server pods in the same namespace as the InferencePool. + // Cross namesoace selector is not supported. // // +kubebuilder:validation:Required Selector map[LabelKey]LabelValue `json:"selector"` diff --git a/client-go/applyconfiguration/api/v1alpha1/endpointpickerconfig.go b/client-go/applyconfiguration/api/v1alpha1/endpointpickerconfig.go deleted file mode 100644 index 91895ddc..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/endpointpickerconfig.go +++ /dev/null @@ -1,38 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -// EndpointPickerConfigApplyConfiguration represents a declarative configuration of the EndpointPickerConfig type for use -// with apply. -type EndpointPickerConfigApplyConfiguration struct { - ExtensionRef *ExtensionApplyConfiguration `json:"extensionRef,omitempty"` -} - -// EndpointPickerConfigApplyConfiguration constructs a declarative configuration of the EndpointPickerConfig type for use with -// apply. -func EndpointPickerConfig() *EndpointPickerConfigApplyConfiguration { - return &EndpointPickerConfigApplyConfiguration{} -} - -// WithExtensionRef sets the ExtensionRef field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the ExtensionRef field is set to the value of the last call. -func (b *EndpointPickerConfigApplyConfiguration) WithExtensionRef(value *ExtensionApplyConfiguration) *EndpointPickerConfigApplyConfiguration { - b.ExtensionRef = value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/extension.go b/client-go/applyconfiguration/api/v1alpha1/extension.go deleted file mode 100644 index 4213af88..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/extension.go +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -// ExtensionApplyConfiguration represents a declarative configuration of the Extension type for use -// with apply. -type ExtensionApplyConfiguration struct { - ExtensionReferenceApplyConfiguration `json:",inline"` - ExtensionConnectionApplyConfiguration `json:",inline"` -} - -// ExtensionApplyConfiguration constructs a declarative configuration of the Extension type for use with -// apply. -func Extension() *ExtensionApplyConfiguration { - return &ExtensionApplyConfiguration{} -} - -// WithGroup sets the Group field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Group field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithGroup(value string) *ExtensionApplyConfiguration { - b.ExtensionReferenceApplyConfiguration.Group = &value - return b -} - -// WithKind sets the Kind field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Kind field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithKind(value string) *ExtensionApplyConfiguration { - b.ExtensionReferenceApplyConfiguration.Kind = &value - return b -} - -// WithName sets the Name field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Name field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithName(value string) *ExtensionApplyConfiguration { - b.ExtensionReferenceApplyConfiguration.Name = &value - return b -} - -// WithTargetPortNumber sets the TargetPortNumber field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the TargetPortNumber field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithTargetPortNumber(value int32) *ExtensionApplyConfiguration { - b.ExtensionReferenceApplyConfiguration.TargetPortNumber = &value - return b -} - -// WithFailureMode sets the FailureMode field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the FailureMode field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithFailureMode(value apiv1alpha1.ExtensionFailureMode) *ExtensionApplyConfiguration { - b.ExtensionConnectionApplyConfiguration.FailureMode = &value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go b/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go deleted file mode 100644 index ff8752a9..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/extensionconnection.go +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -// ExtensionConnectionApplyConfiguration represents a declarative configuration of the ExtensionConnection type for use -// with apply. -type ExtensionConnectionApplyConfiguration struct { - FailureMode *apiv1alpha1.ExtensionFailureMode `json:"failureMode,omitempty"` -} - -// ExtensionConnectionApplyConfiguration constructs a declarative configuration of the ExtensionConnection type for use with -// apply. -func ExtensionConnection() *ExtensionConnectionApplyConfiguration { - return &ExtensionConnectionApplyConfiguration{} -} - -// WithFailureMode sets the FailureMode field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the FailureMode field is set to the value of the last call. -func (b *ExtensionConnectionApplyConfiguration) WithFailureMode(value apiv1alpha1.ExtensionFailureMode) *ExtensionConnectionApplyConfiguration { - b.FailureMode = &value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/extensionreference.go b/client-go/applyconfiguration/api/v1alpha1/extensionreference.go deleted file mode 100644 index c72c0306..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/extensionreference.go +++ /dev/null @@ -1,65 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -// ExtensionReferenceApplyConfiguration represents a declarative configuration of the ExtensionReference type for use -// with apply. -type ExtensionReferenceApplyConfiguration struct { - Group *string `json:"group,omitempty"` - Kind *string `json:"kind,omitempty"` - Name *string `json:"name,omitempty"` - TargetPortNumber *int32 `json:"targetPortNumber,omitempty"` -} - -// ExtensionReferenceApplyConfiguration constructs a declarative configuration of the ExtensionReference type for use with -// apply. -func ExtensionReference() *ExtensionReferenceApplyConfiguration { - return &ExtensionReferenceApplyConfiguration{} -} - -// WithGroup sets the Group field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Group field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithGroup(value string) *ExtensionReferenceApplyConfiguration { - b.Group = &value - return b -} - -// WithKind sets the Kind field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Kind field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithKind(value string) *ExtensionReferenceApplyConfiguration { - b.Kind = &value - return b -} - -// WithName sets the Name field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Name field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithName(value string) *ExtensionReferenceApplyConfiguration { - b.Name = &value - return b -} - -// WithTargetPortNumber sets the TargetPortNumber field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the TargetPortNumber field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithTargetPortNumber(value int32) *ExtensionReferenceApplyConfiguration { - b.TargetPortNumber = &value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go b/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go deleted file mode 100644 index d2a5b2b4..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/inferencemodel.go +++ /dev/null @@ -1,224 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - types "k8s.io/apimachinery/pkg/types" - v1 "k8s.io/client-go/applyconfigurations/meta/v1" -) - -// InferenceModelApplyConfiguration represents a declarative configuration of the InferenceModel type for use -// with apply. -type InferenceModelApplyConfiguration struct { - v1.TypeMetaApplyConfiguration `json:",inline"` - *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` - Spec *InferenceModelSpecApplyConfiguration `json:"spec,omitempty"` - Status *InferenceModelStatusApplyConfiguration `json:"status,omitempty"` -} - -// InferenceModel constructs a declarative configuration of the InferenceModel type for use with -// apply. -func InferenceModel(name, namespace string) *InferenceModelApplyConfiguration { - b := &InferenceModelApplyConfiguration{} - b.WithName(name) - b.WithNamespace(namespace) - b.WithKind("InferenceModel") - b.WithAPIVersion("inference.networking.x-k8s.io/v1alpha1") - return b -} - -// WithKind sets the Kind field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Kind field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithKind(value string) *InferenceModelApplyConfiguration { - b.TypeMetaApplyConfiguration.Kind = &value - return b -} - -// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the APIVersion field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithAPIVersion(value string) *InferenceModelApplyConfiguration { - b.TypeMetaApplyConfiguration.APIVersion = &value - return b -} - -// WithName sets the Name field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Name field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithName(value string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.Name = &value - return b -} - -// WithGenerateName sets the GenerateName field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the GenerateName field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithGenerateName(value string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.GenerateName = &value - return b -} - -// WithNamespace sets the Namespace field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Namespace field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithNamespace(value string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.Namespace = &value - return b -} - -// WithUID sets the UID field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the UID field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithUID(value types.UID) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.UID = &value - return b -} - -// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the ResourceVersion field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithResourceVersion(value string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.ResourceVersion = &value - return b -} - -// WithGeneration sets the Generation field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Generation field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithGeneration(value int64) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.Generation = &value - return b -} - -// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the CreationTimestamp field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithCreationTimestamp(value metav1.Time) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.CreationTimestamp = &value - return b -} - -// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the DeletionTimestamp field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value - return b -} - -// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value - return b -} - -// WithLabels puts the entries into the Labels field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, the entries provided by each call will be put on the Labels field, -// overwriting an existing map entries in Labels field with the same key. -func (b *InferenceModelApplyConfiguration) WithLabels(entries map[string]string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { - b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) - } - for k, v := range entries { - b.ObjectMetaApplyConfiguration.Labels[k] = v - } - return b -} - -// WithAnnotations puts the entries into the Annotations field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, the entries provided by each call will be put on the Annotations field, -// overwriting an existing map entries in Annotations field with the same key. -func (b *InferenceModelApplyConfiguration) WithAnnotations(entries map[string]string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { - b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) - } - for k, v := range entries { - b.ObjectMetaApplyConfiguration.Annotations[k] = v - } - return b -} - -// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the OwnerReferences field. -func (b *InferenceModelApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - for i := range values { - if values[i] == nil { - panic("nil value passed to WithOwnerReferences") - } - b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) - } - return b -} - -// WithFinalizers adds the given value to the Finalizers field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the Finalizers field. -func (b *InferenceModelApplyConfiguration) WithFinalizers(values ...string) *InferenceModelApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - for i := range values { - b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) - } - return b -} - -func (b *InferenceModelApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { - if b.ObjectMetaApplyConfiguration == nil { - b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} - } -} - -// WithSpec sets the Spec field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Spec field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithSpec(value *InferenceModelSpecApplyConfiguration) *InferenceModelApplyConfiguration { - b.Spec = value - return b -} - -// WithStatus sets the Status field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Status field is set to the value of the last call. -func (b *InferenceModelApplyConfiguration) WithStatus(value *InferenceModelStatusApplyConfiguration) *InferenceModelApplyConfiguration { - b.Status = value - return b -} - -// GetName retrieves the value of the Name field in the declarative configuration. -func (b *InferenceModelApplyConfiguration) GetName() *string { - b.ensureObjectMetaApplyConfigurationExists() - return b.ObjectMetaApplyConfiguration.Name -} diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go b/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go deleted file mode 100644 index 2b1a4cbf..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/inferencemodelspec.go +++ /dev/null @@ -1,74 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -// InferenceModelSpecApplyConfiguration represents a declarative configuration of the InferenceModelSpec type for use -// with apply. -type InferenceModelSpecApplyConfiguration struct { - ModelName *string `json:"modelName,omitempty"` - Criticality *apiv1alpha1.Criticality `json:"criticality,omitempty"` - TargetModels []TargetModelApplyConfiguration `json:"targetModels,omitempty"` - PoolRef *PoolObjectReferenceApplyConfiguration `json:"poolRef,omitempty"` -} - -// InferenceModelSpecApplyConfiguration constructs a declarative configuration of the InferenceModelSpec type for use with -// apply. -func InferenceModelSpec() *InferenceModelSpecApplyConfiguration { - return &InferenceModelSpecApplyConfiguration{} -} - -// WithModelName sets the ModelName field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the ModelName field is set to the value of the last call. -func (b *InferenceModelSpecApplyConfiguration) WithModelName(value string) *InferenceModelSpecApplyConfiguration { - b.ModelName = &value - return b -} - -// WithCriticality sets the Criticality field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Criticality field is set to the value of the last call. -func (b *InferenceModelSpecApplyConfiguration) WithCriticality(value apiv1alpha1.Criticality) *InferenceModelSpecApplyConfiguration { - b.Criticality = &value - return b -} - -// WithTargetModels adds the given value to the TargetModels field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the TargetModels field. -func (b *InferenceModelSpecApplyConfiguration) WithTargetModels(values ...*TargetModelApplyConfiguration) *InferenceModelSpecApplyConfiguration { - for i := range values { - if values[i] == nil { - panic("nil value passed to WithTargetModels") - } - b.TargetModels = append(b.TargetModels, *values[i]) - } - return b -} - -// WithPoolRef sets the PoolRef field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the PoolRef field is set to the value of the last call. -func (b *InferenceModelSpecApplyConfiguration) WithPoolRef(value *PoolObjectReferenceApplyConfiguration) *InferenceModelSpecApplyConfiguration { - b.PoolRef = value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencemodelstatus.go b/client-go/applyconfiguration/api/v1alpha1/inferencemodelstatus.go deleted file mode 100644 index b0b003bb..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/inferencemodelstatus.go +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - v1 "k8s.io/client-go/applyconfigurations/meta/v1" -) - -// InferenceModelStatusApplyConfiguration represents a declarative configuration of the InferenceModelStatus type for use -// with apply. -type InferenceModelStatusApplyConfiguration struct { - Conditions []v1.ConditionApplyConfiguration `json:"conditions,omitempty"` -} - -// InferenceModelStatusApplyConfiguration constructs a declarative configuration of the InferenceModelStatus type for use with -// apply. -func InferenceModelStatus() *InferenceModelStatusApplyConfiguration { - return &InferenceModelStatusApplyConfiguration{} -} - -// WithConditions adds the given value to the Conditions field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the Conditions field. -func (b *InferenceModelStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *InferenceModelStatusApplyConfiguration { - for i := range values { - if values[i] == nil { - panic("nil value passed to WithConditions") - } - b.Conditions = append(b.Conditions, *values[i]) - } - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencepool.go b/client-go/applyconfiguration/api/v1alpha1/inferencepool.go deleted file mode 100644 index 2940143e..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/inferencepool.go +++ /dev/null @@ -1,224 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - types "k8s.io/apimachinery/pkg/types" - v1 "k8s.io/client-go/applyconfigurations/meta/v1" -) - -// InferencePoolApplyConfiguration represents a declarative configuration of the InferencePool type for use -// with apply. -type InferencePoolApplyConfiguration struct { - v1.TypeMetaApplyConfiguration `json:",inline"` - *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` - Spec *InferencePoolSpecApplyConfiguration `json:"spec,omitempty"` - Status *InferencePoolStatusApplyConfiguration `json:"status,omitempty"` -} - -// InferencePool constructs a declarative configuration of the InferencePool type for use with -// apply. -func InferencePool(name, namespace string) *InferencePoolApplyConfiguration { - b := &InferencePoolApplyConfiguration{} - b.WithName(name) - b.WithNamespace(namespace) - b.WithKind("InferencePool") - b.WithAPIVersion("inference.networking.x-k8s.io/v1alpha1") - return b -} - -// WithKind sets the Kind field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Kind field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithKind(value string) *InferencePoolApplyConfiguration { - b.TypeMetaApplyConfiguration.Kind = &value - return b -} - -// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the APIVersion field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithAPIVersion(value string) *InferencePoolApplyConfiguration { - b.TypeMetaApplyConfiguration.APIVersion = &value - return b -} - -// WithName sets the Name field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Name field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithName(value string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.Name = &value - return b -} - -// WithGenerateName sets the GenerateName field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the GenerateName field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithGenerateName(value string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.GenerateName = &value - return b -} - -// WithNamespace sets the Namespace field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Namespace field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithNamespace(value string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.Namespace = &value - return b -} - -// WithUID sets the UID field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the UID field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithUID(value types.UID) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.UID = &value - return b -} - -// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the ResourceVersion field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithResourceVersion(value string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.ResourceVersion = &value - return b -} - -// WithGeneration sets the Generation field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Generation field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithGeneration(value int64) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.Generation = &value - return b -} - -// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the CreationTimestamp field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithCreationTimestamp(value metav1.Time) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.CreationTimestamp = &value - return b -} - -// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the DeletionTimestamp field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value - return b -} - -// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value - return b -} - -// WithLabels puts the entries into the Labels field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, the entries provided by each call will be put on the Labels field, -// overwriting an existing map entries in Labels field with the same key. -func (b *InferencePoolApplyConfiguration) WithLabels(entries map[string]string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 { - b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries)) - } - for k, v := range entries { - b.ObjectMetaApplyConfiguration.Labels[k] = v - } - return b -} - -// WithAnnotations puts the entries into the Annotations field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, the entries provided by each call will be put on the Annotations field, -// overwriting an existing map entries in Annotations field with the same key. -func (b *InferencePoolApplyConfiguration) WithAnnotations(entries map[string]string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 { - b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries)) - } - for k, v := range entries { - b.ObjectMetaApplyConfiguration.Annotations[k] = v - } - return b -} - -// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the OwnerReferences field. -func (b *InferencePoolApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - for i := range values { - if values[i] == nil { - panic("nil value passed to WithOwnerReferences") - } - b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i]) - } - return b -} - -// WithFinalizers adds the given value to the Finalizers field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the Finalizers field. -func (b *InferencePoolApplyConfiguration) WithFinalizers(values ...string) *InferencePoolApplyConfiguration { - b.ensureObjectMetaApplyConfigurationExists() - for i := range values { - b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i]) - } - return b -} - -func (b *InferencePoolApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { - if b.ObjectMetaApplyConfiguration == nil { - b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} - } -} - -// WithSpec sets the Spec field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Spec field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithSpec(value *InferencePoolSpecApplyConfiguration) *InferencePoolApplyConfiguration { - b.Spec = value - return b -} - -// WithStatus sets the Status field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Status field is set to the value of the last call. -func (b *InferencePoolApplyConfiguration) WithStatus(value *InferencePoolStatusApplyConfiguration) *InferencePoolApplyConfiguration { - b.Status = value - return b -} - -// GetName retrieves the value of the Name field in the declarative configuration. -func (b *InferencePoolApplyConfiguration) GetName() *string { - b.ensureObjectMetaApplyConfigurationExists() - return b.ObjectMetaApplyConfiguration.Name -} diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go b/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go deleted file mode 100644 index 5f69a154..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/inferencepoolspec.go +++ /dev/null @@ -1,66 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -// InferencePoolSpecApplyConfiguration represents a declarative configuration of the InferencePoolSpec type for use -// with apply. -type InferencePoolSpecApplyConfiguration struct { - Selector map[apiv1alpha1.LabelKey]apiv1alpha1.LabelValue `json:"selector,omitempty"` - TargetPortNumber *int32 `json:"targetPortNumber,omitempty"` - EndpointPickerConfigApplyConfiguration `json:",inline"` -} - -// InferencePoolSpecApplyConfiguration constructs a declarative configuration of the InferencePoolSpec type for use with -// apply. -func InferencePoolSpec() *InferencePoolSpecApplyConfiguration { - return &InferencePoolSpecApplyConfiguration{} -} - -// WithSelector puts the entries into the Selector field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, the entries provided by each call will be put on the Selector field, -// overwriting an existing map entries in Selector field with the same key. -func (b *InferencePoolSpecApplyConfiguration) WithSelector(entries map[apiv1alpha1.LabelKey]apiv1alpha1.LabelValue) *InferencePoolSpecApplyConfiguration { - if b.Selector == nil && len(entries) > 0 { - b.Selector = make(map[apiv1alpha1.LabelKey]apiv1alpha1.LabelValue, len(entries)) - } - for k, v := range entries { - b.Selector[k] = v - } - return b -} - -// WithTargetPortNumber sets the TargetPortNumber field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the TargetPortNumber field is set to the value of the last call. -func (b *InferencePoolSpecApplyConfiguration) WithTargetPortNumber(value int32) *InferencePoolSpecApplyConfiguration { - b.TargetPortNumber = &value - return b -} - -// WithExtensionRef sets the ExtensionRef field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the ExtensionRef field is set to the value of the last call. -func (b *InferencePoolSpecApplyConfiguration) WithExtensionRef(value *ExtensionApplyConfiguration) *InferencePoolSpecApplyConfiguration { - b.EndpointPickerConfigApplyConfiguration.ExtensionRef = value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/inferencepoolstatus.go b/client-go/applyconfiguration/api/v1alpha1/inferencepoolstatus.go deleted file mode 100644 index f61a81b3..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/inferencepoolstatus.go +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - v1 "k8s.io/client-go/applyconfigurations/meta/v1" -) - -// InferencePoolStatusApplyConfiguration represents a declarative configuration of the InferencePoolStatus type for use -// with apply. -type InferencePoolStatusApplyConfiguration struct { - Conditions []v1.ConditionApplyConfiguration `json:"conditions,omitempty"` -} - -// InferencePoolStatusApplyConfiguration constructs a declarative configuration of the InferencePoolStatus type for use with -// apply. -func InferencePoolStatus() *InferencePoolStatusApplyConfiguration { - return &InferencePoolStatusApplyConfiguration{} -} - -// WithConditions adds the given value to the Conditions field in the declarative configuration -// and returns the receiver, so that objects can be build by chaining "With" function invocations. -// If called multiple times, values provided by each call will be appended to the Conditions field. -func (b *InferencePoolStatusApplyConfiguration) WithConditions(values ...*v1.ConditionApplyConfiguration) *InferencePoolStatusApplyConfiguration { - for i := range values { - if values[i] == nil { - panic("nil value passed to WithConditions") - } - b.Conditions = append(b.Conditions, *values[i]) - } - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/poolobjectreference.go b/client-go/applyconfiguration/api/v1alpha1/poolobjectreference.go deleted file mode 100644 index 692a185e..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/poolobjectreference.go +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -// PoolObjectReferenceApplyConfiguration represents a declarative configuration of the PoolObjectReference type for use -// with apply. -type PoolObjectReferenceApplyConfiguration struct { - Group *string `json:"group,omitempty"` - Kind *string `json:"kind,omitempty"` - Name *string `json:"name,omitempty"` -} - -// PoolObjectReferenceApplyConfiguration constructs a declarative configuration of the PoolObjectReference type for use with -// apply. -func PoolObjectReference() *PoolObjectReferenceApplyConfiguration { - return &PoolObjectReferenceApplyConfiguration{} -} - -// WithGroup sets the Group field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Group field is set to the value of the last call. -func (b *PoolObjectReferenceApplyConfiguration) WithGroup(value string) *PoolObjectReferenceApplyConfiguration { - b.Group = &value - return b -} - -// WithKind sets the Kind field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Kind field is set to the value of the last call. -func (b *PoolObjectReferenceApplyConfiguration) WithKind(value string) *PoolObjectReferenceApplyConfiguration { - b.Kind = &value - return b -} - -// WithName sets the Name field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Name field is set to the value of the last call. -func (b *PoolObjectReferenceApplyConfiguration) WithName(value string) *PoolObjectReferenceApplyConfiguration { - b.Name = &value - return b -} diff --git a/client-go/applyconfiguration/api/v1alpha1/targetmodel.go b/client-go/applyconfiguration/api/v1alpha1/targetmodel.go deleted file mode 100644 index f6ac83f8..00000000 --- a/client-go/applyconfiguration/api/v1alpha1/targetmodel.go +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1alpha1 - -// TargetModelApplyConfiguration represents a declarative configuration of the TargetModel type for use -// with apply. -type TargetModelApplyConfiguration struct { - Name *string `json:"name,omitempty"` - Weight *int32 `json:"weight,omitempty"` -} - -// TargetModelApplyConfiguration constructs a declarative configuration of the TargetModel type for use with -// apply. -func TargetModel() *TargetModelApplyConfiguration { - return &TargetModelApplyConfiguration{} -} - -// WithName sets the Name field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Name field is set to the value of the last call. -func (b *TargetModelApplyConfiguration) WithName(value string) *TargetModelApplyConfiguration { - b.Name = &value - return b -} - -// WithWeight sets the Weight field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Weight field is set to the value of the last call. -func (b *TargetModelApplyConfiguration) WithWeight(value int32) *TargetModelApplyConfiguration { - b.Weight = &value - return b -} diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index eacc9c43..e1ad5ea4 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -21,9 +21,7 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" schema "k8s.io/apimachinery/pkg/runtime/schema" testing "k8s.io/client-go/testing" - v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha2" internal "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/internal" ) @@ -32,33 +30,7 @@ import ( // apply configuration type exists for the given GroupVersionKind. func ForKind(kind schema.GroupVersionKind) interface{} { switch kind { - // Group=inference.networking.x-k8s.io, Version=v1alpha1 - case v1alpha1.SchemeGroupVersion.WithKind("EndpointPickerConfig"): - return &apiv1alpha1.EndpointPickerConfigApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("Extension"): - return &apiv1alpha1.ExtensionApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("ExtensionConnection"): - return &apiv1alpha1.ExtensionConnectionApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("ExtensionReference"): - return &apiv1alpha1.ExtensionReferenceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("InferenceModel"): - return &apiv1alpha1.InferenceModelApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("InferenceModelSpec"): - return &apiv1alpha1.InferenceModelSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("InferenceModelStatus"): - return &apiv1alpha1.InferenceModelStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("InferencePool"): - return &apiv1alpha1.InferencePoolApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("InferencePoolSpec"): - return &apiv1alpha1.InferencePoolSpecApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("InferencePoolStatus"): - return &apiv1alpha1.InferencePoolStatusApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("PoolObjectReference"): - return &apiv1alpha1.PoolObjectReferenceApplyConfiguration{} - case v1alpha1.SchemeGroupVersion.WithKind("TargetModel"): - return &apiv1alpha1.TargetModelApplyConfiguration{} - - // Group=inference.networking.x-k8s.io, Version=v1alpha2 + // Group=inference.networking.x-k8s.io, Version=v1alpha2 case v1alpha2.SchemeGroupVersion.WithKind("EndpointPickerConfig"): return &apiv1alpha2.EndpointPickerConfigApplyConfiguration{} case v1alpha2.SchemeGroupVersion.WithKind("Extension"): diff --git a/client-go/clientset/versioned/clientset.go b/client-go/clientset/versioned/clientset.go index 4266285a..c56d11c7 100644 --- a/client-go/clientset/versioned/clientset.go +++ b/client-go/clientset/versioned/clientset.go @@ -24,28 +24,20 @@ import ( discovery "k8s.io/client-go/discovery" rest "k8s.io/client-go/rest" flowcontrol "k8s.io/client-go/util/flowcontrol" - inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" ) type Interface interface { Discovery() discovery.DiscoveryInterface - InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface InferenceV1alpha2() inferencev1alpha2.InferenceV1alpha2Interface } // Clientset contains the clients for groups. type Clientset struct { *discovery.DiscoveryClient - inferenceV1alpha1 *inferencev1alpha1.InferenceV1alpha1Client inferenceV1alpha2 *inferencev1alpha2.InferenceV1alpha2Client } -// InferenceV1alpha1 retrieves the InferenceV1alpha1Client -func (c *Clientset) InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface { - return c.inferenceV1alpha1 -} - // InferenceV1alpha2 retrieves the InferenceV1alpha2Client func (c *Clientset) InferenceV1alpha2() inferencev1alpha2.InferenceV1alpha2Interface { return c.inferenceV1alpha2 @@ -95,10 +87,6 @@ func NewForConfigAndClient(c *rest.Config, httpClient *http.Client) (*Clientset, var cs Clientset var err error - cs.inferenceV1alpha1, err = inferencev1alpha1.NewForConfigAndClient(&configShallowCopy, httpClient) - if err != nil { - return nil, err - } cs.inferenceV1alpha2, err = inferencev1alpha2.NewForConfigAndClient(&configShallowCopy, httpClient) if err != nil { return nil, err @@ -124,7 +112,6 @@ func NewForConfigOrDie(c *rest.Config) *Clientset { // New creates a new Clientset for the given RESTClient. func New(c rest.Interface) *Clientset { var cs Clientset - cs.inferenceV1alpha1 = inferencev1alpha1.New(c) cs.inferenceV1alpha2 = inferencev1alpha2.New(c) cs.DiscoveryClient = discovery.NewDiscoveryClient(c) diff --git a/client-go/clientset/versioned/fake/clientset_generated.go b/client-go/clientset/versioned/fake/clientset_generated.go index f4f33032..b0ecd50b 100644 --- a/client-go/clientset/versioned/fake/clientset_generated.go +++ b/client-go/clientset/versioned/fake/clientset_generated.go @@ -25,8 +25,6 @@ import ( "k8s.io/client-go/testing" applyconfiguration "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration" clientset "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" - fakeinferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1/fake" inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2" fakeinferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha2/fake" ) @@ -117,11 +115,6 @@ var ( _ testing.FakeClient = &Clientset{} ) -// InferenceV1alpha1 retrieves the InferenceV1alpha1Client -func (c *Clientset) InferenceV1alpha1() inferencev1alpha1.InferenceV1alpha1Interface { - return &fakeinferencev1alpha1.FakeInferenceV1alpha1{Fake: &c.Fake} -} - // InferenceV1alpha2 retrieves the InferenceV1alpha2Client func (c *Clientset) InferenceV1alpha2() inferencev1alpha2.InferenceV1alpha2Interface { return &fakeinferencev1alpha2.FakeInferenceV1alpha2{Fake: &c.Fake} diff --git a/client-go/clientset/versioned/fake/register.go b/client-go/clientset/versioned/fake/register.go index bc8e6903..365ccb75 100644 --- a/client-go/clientset/versioned/fake/register.go +++ b/client-go/clientset/versioned/fake/register.go @@ -23,7 +23,6 @@ import ( schema "k8s.io/apimachinery/pkg/runtime/schema" serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -31,7 +30,6 @@ var scheme = runtime.NewScheme() var codecs = serializer.NewCodecFactory(scheme) var localSchemeBuilder = runtime.SchemeBuilder{ - inferencev1alpha1.AddToScheme, inferencev1alpha2.AddToScheme, } diff --git a/client-go/clientset/versioned/scheme/register.go b/client-go/clientset/versioned/scheme/register.go index 5727d404..b656f121 100644 --- a/client-go/clientset/versioned/scheme/register.go +++ b/client-go/clientset/versioned/scheme/register.go @@ -23,7 +23,6 @@ import ( schema "k8s.io/apimachinery/pkg/runtime/schema" serializer "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - inferencev1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -31,7 +30,6 @@ var Scheme = runtime.NewScheme() var Codecs = serializer.NewCodecFactory(Scheme) var ParameterCodec = runtime.NewParameterCodec(Scheme) var localSchemeBuilder = runtime.SchemeBuilder{ - inferencev1alpha1.AddToScheme, inferencev1alpha2.AddToScheme, } diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go b/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go deleted file mode 100644 index 8cc8a643..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/api_client.go +++ /dev/null @@ -1,111 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - http "net/http" - - rest "k8s.io/client-go/rest" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" -) - -type InferenceV1alpha1Interface interface { - RESTClient() rest.Interface - InferenceModelsGetter - InferencePoolsGetter -} - -// InferenceV1alpha1Client is used to interact with features provided by the inference.networking.x-k8s.io group. -type InferenceV1alpha1Client struct { - restClient rest.Interface -} - -func (c *InferenceV1alpha1Client) InferenceModels(namespace string) InferenceModelInterface { - return newInferenceModels(c, namespace) -} - -func (c *InferenceV1alpha1Client) InferencePools(namespace string) InferencePoolInterface { - return newInferencePools(c, namespace) -} - -// NewForConfig creates a new InferenceV1alpha1Client for the given config. -// NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), -// where httpClient was generated with rest.HTTPClientFor(c). -func NewForConfig(c *rest.Config) (*InferenceV1alpha1Client, error) { - config := *c - if err := setConfigDefaults(&config); err != nil { - return nil, err - } - httpClient, err := rest.HTTPClientFor(&config) - if err != nil { - return nil, err - } - return NewForConfigAndClient(&config, httpClient) -} - -// NewForConfigAndClient creates a new InferenceV1alpha1Client for the given config and http client. -// Note the http client provided takes precedence over the configured transport values. -func NewForConfigAndClient(c *rest.Config, h *http.Client) (*InferenceV1alpha1Client, error) { - config := *c - if err := setConfigDefaults(&config); err != nil { - return nil, err - } - client, err := rest.RESTClientForConfigAndClient(&config, h) - if err != nil { - return nil, err - } - return &InferenceV1alpha1Client{client}, nil -} - -// NewForConfigOrDie creates a new InferenceV1alpha1Client for the given config and -// panics if there is an error in the config. -func NewForConfigOrDie(c *rest.Config) *InferenceV1alpha1Client { - client, err := NewForConfig(c) - if err != nil { - panic(err) - } - return client -} - -// New creates a new InferenceV1alpha1Client for the given RESTClient. -func New(c rest.Interface) *InferenceV1alpha1Client { - return &InferenceV1alpha1Client{c} -} - -func setConfigDefaults(config *rest.Config) error { - gv := apiv1alpha1.SchemeGroupVersion - config.GroupVersion = &gv - config.APIPath = "/apis" - config.NegotiatedSerializer = rest.CodecFactoryForGeneratedClient(scheme.Scheme, scheme.Codecs).WithoutConversion() - - if config.UserAgent == "" { - config.UserAgent = rest.DefaultKubernetesUserAgent() - } - - return nil -} - -// RESTClient returns a RESTClient that is used to communicate -// with API server by this client implementation. -func (c *InferenceV1alpha1Client) RESTClient() rest.Interface { - if c == nil { - return nil - } - return c.restClient -} diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/doc.go b/client-go/clientset/versioned/typed/api/v1alpha1/doc.go deleted file mode 100644 index 28991e22..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -// This package has the automatically generated typed clients. -package v1alpha1 diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/doc.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/doc.go deleted file mode 100644 index fbfccbb9..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -// Package fake has the automatically generated clients. -package fake diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go deleted file mode 100644 index 1dee0f20..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package fake - -import ( - rest "k8s.io/client-go/rest" - testing "k8s.io/client-go/testing" - v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" -) - -type FakeInferenceV1alpha1 struct { - *testing.Fake -} - -func (c *FakeInferenceV1alpha1) InferenceModels(namespace string) v1alpha1.InferenceModelInterface { - return newFakeInferenceModels(c, namespace) -} - -func (c *FakeInferenceV1alpha1) InferencePools(namespace string) v1alpha1.InferencePoolInterface { - return newFakeInferencePools(c, namespace) -} - -// RESTClient returns a RESTClient that is used to communicate -// with API server by this client implementation. -func (c *FakeInferenceV1alpha1) RESTClient() rest.Interface { - var ret *rest.RESTClient - return ret -} diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go deleted file mode 100644 index 44007ae7..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencemodel.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package fake - -import ( - gentype "k8s.io/client-go/gentype" - v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - typedapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" -) - -// fakeInferenceModels implements InferenceModelInterface -type fakeInferenceModels struct { - *gentype.FakeClientWithListAndApply[*v1alpha1.InferenceModel, *v1alpha1.InferenceModelList, *apiv1alpha1.InferenceModelApplyConfiguration] - Fake *FakeInferenceV1alpha1 -} - -func newFakeInferenceModels(fake *FakeInferenceV1alpha1, namespace string) typedapiv1alpha1.InferenceModelInterface { - return &fakeInferenceModels{ - gentype.NewFakeClientWithListAndApply[*v1alpha1.InferenceModel, *v1alpha1.InferenceModelList, *apiv1alpha1.InferenceModelApplyConfiguration]( - fake.Fake, - namespace, - v1alpha1.SchemeGroupVersion.WithResource("inferencemodels"), - v1alpha1.SchemeGroupVersion.WithKind("InferenceModel"), - func() *v1alpha1.InferenceModel { return &v1alpha1.InferenceModel{} }, - func() *v1alpha1.InferenceModelList { return &v1alpha1.InferenceModelList{} }, - func(dst, src *v1alpha1.InferenceModelList) { dst.ListMeta = src.ListMeta }, - func(list *v1alpha1.InferenceModelList) []*v1alpha1.InferenceModel { - return gentype.ToPointerSlice(list.Items) - }, - func(list *v1alpha1.InferenceModelList, items []*v1alpha1.InferenceModel) { - list.Items = gentype.FromPointerSlice(items) - }, - ), - fake, - } -} diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go deleted file mode 100644 index cd0764aa..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/fake/fake_inferencepool.go +++ /dev/null @@ -1,52 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package fake - -import ( - gentype "k8s.io/client-go/gentype" - v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - typedapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/typed/api/v1alpha1" -) - -// fakeInferencePools implements InferencePoolInterface -type fakeInferencePools struct { - *gentype.FakeClientWithListAndApply[*v1alpha1.InferencePool, *v1alpha1.InferencePoolList, *apiv1alpha1.InferencePoolApplyConfiguration] - Fake *FakeInferenceV1alpha1 -} - -func newFakeInferencePools(fake *FakeInferenceV1alpha1, namespace string) typedapiv1alpha1.InferencePoolInterface { - return &fakeInferencePools{ - gentype.NewFakeClientWithListAndApply[*v1alpha1.InferencePool, *v1alpha1.InferencePoolList, *apiv1alpha1.InferencePoolApplyConfiguration]( - fake.Fake, - namespace, - v1alpha1.SchemeGroupVersion.WithResource("inferencepools"), - v1alpha1.SchemeGroupVersion.WithKind("InferencePool"), - func() *v1alpha1.InferencePool { return &v1alpha1.InferencePool{} }, - func() *v1alpha1.InferencePoolList { return &v1alpha1.InferencePoolList{} }, - func(dst, src *v1alpha1.InferencePoolList) { dst.ListMeta = src.ListMeta }, - func(list *v1alpha1.InferencePoolList) []*v1alpha1.InferencePool { - return gentype.ToPointerSlice(list.Items) - }, - func(list *v1alpha1.InferencePoolList, items []*v1alpha1.InferencePool) { - list.Items = gentype.FromPointerSlice(items) - }, - ), - fake, - } -} diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/generated_expansion.go b/client-go/clientset/versioned/typed/api/v1alpha1/generated_expansion.go deleted file mode 100644 index 65c88eb1..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/generated_expansion.go +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package v1alpha1 - -type InferenceModelExpansion interface{} - -type InferencePoolExpansion interface{} diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go deleted file mode 100644 index 4c7c5941..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/inferencemodel.go +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - context "context" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - types "k8s.io/apimachinery/pkg/types" - watch "k8s.io/apimachinery/pkg/watch" - gentype "k8s.io/client-go/gentype" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - applyconfigurationapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" -) - -// InferenceModelsGetter has a method to return a InferenceModelInterface. -// A group's client should implement this interface. -type InferenceModelsGetter interface { - InferenceModels(namespace string) InferenceModelInterface -} - -// InferenceModelInterface has methods to work with InferenceModel resources. -type InferenceModelInterface interface { - Create(ctx context.Context, inferenceModel *apiv1alpha1.InferenceModel, opts v1.CreateOptions) (*apiv1alpha1.InferenceModel, error) - Update(ctx context.Context, inferenceModel *apiv1alpha1.InferenceModel, opts v1.UpdateOptions) (*apiv1alpha1.InferenceModel, error) - // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). - UpdateStatus(ctx context.Context, inferenceModel *apiv1alpha1.InferenceModel, opts v1.UpdateOptions) (*apiv1alpha1.InferenceModel, error) - Delete(ctx context.Context, name string, opts v1.DeleteOptions) error - DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error - Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.InferenceModel, error) - List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.InferenceModelList, error) - Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) - Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.InferenceModel, err error) - Apply(ctx context.Context, inferenceModel *applyconfigurationapiv1alpha1.InferenceModelApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha1.InferenceModel, err error) - // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). - ApplyStatus(ctx context.Context, inferenceModel *applyconfigurationapiv1alpha1.InferenceModelApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha1.InferenceModel, err error) - InferenceModelExpansion -} - -// inferenceModels implements InferenceModelInterface -type inferenceModels struct { - *gentype.ClientWithListAndApply[*apiv1alpha1.InferenceModel, *apiv1alpha1.InferenceModelList, *applyconfigurationapiv1alpha1.InferenceModelApplyConfiguration] -} - -// newInferenceModels returns a InferenceModels -func newInferenceModels(c *InferenceV1alpha1Client, namespace string) *inferenceModels { - return &inferenceModels{ - gentype.NewClientWithListAndApply[*apiv1alpha1.InferenceModel, *apiv1alpha1.InferenceModelList, *applyconfigurationapiv1alpha1.InferenceModelApplyConfiguration]( - "inferencemodels", - c.RESTClient(), - scheme.ParameterCodec, - namespace, - func() *apiv1alpha1.InferenceModel { return &apiv1alpha1.InferenceModel{} }, - func() *apiv1alpha1.InferenceModelList { return &apiv1alpha1.InferenceModelList{} }, - ), - } -} diff --git a/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go deleted file mode 100644 index 9af91801..00000000 --- a/client-go/clientset/versioned/typed/api/v1alpha1/inferencepool.go +++ /dev/null @@ -1,73 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by client-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - context "context" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - types "k8s.io/apimachinery/pkg/types" - watch "k8s.io/apimachinery/pkg/watch" - gentype "k8s.io/client-go/gentype" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - applyconfigurationapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/applyconfiguration/api/v1alpha1" - scheme "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned/scheme" -) - -// InferencePoolsGetter has a method to return a InferencePoolInterface. -// A group's client should implement this interface. -type InferencePoolsGetter interface { - InferencePools(namespace string) InferencePoolInterface -} - -// InferencePoolInterface has methods to work with InferencePool resources. -type InferencePoolInterface interface { - Create(ctx context.Context, inferencePool *apiv1alpha1.InferencePool, opts v1.CreateOptions) (*apiv1alpha1.InferencePool, error) - Update(ctx context.Context, inferencePool *apiv1alpha1.InferencePool, opts v1.UpdateOptions) (*apiv1alpha1.InferencePool, error) - // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). - UpdateStatus(ctx context.Context, inferencePool *apiv1alpha1.InferencePool, opts v1.UpdateOptions) (*apiv1alpha1.InferencePool, error) - Delete(ctx context.Context, name string, opts v1.DeleteOptions) error - DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error - Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.InferencePool, error) - List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.InferencePoolList, error) - Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) - Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.InferencePool, err error) - Apply(ctx context.Context, inferencePool *applyconfigurationapiv1alpha1.InferencePoolApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha1.InferencePool, err error) - // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). - ApplyStatus(ctx context.Context, inferencePool *applyconfigurationapiv1alpha1.InferencePoolApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha1.InferencePool, err error) - InferencePoolExpansion -} - -// inferencePools implements InferencePoolInterface -type inferencePools struct { - *gentype.ClientWithListAndApply[*apiv1alpha1.InferencePool, *apiv1alpha1.InferencePoolList, *applyconfigurationapiv1alpha1.InferencePoolApplyConfiguration] -} - -// newInferencePools returns a InferencePools -func newInferencePools(c *InferenceV1alpha1Client, namespace string) *inferencePools { - return &inferencePools{ - gentype.NewClientWithListAndApply[*apiv1alpha1.InferencePool, *apiv1alpha1.InferencePoolList, *applyconfigurationapiv1alpha1.InferencePoolApplyConfiguration]( - "inferencepools", - c.RESTClient(), - scheme.ParameterCodec, - namespace, - func() *apiv1alpha1.InferencePool { return &apiv1alpha1.InferencePool{} }, - func() *apiv1alpha1.InferencePoolList { return &apiv1alpha1.InferencePoolList{} }, - ), - } -} diff --git a/client-go/informers/externalversions/api/interface.go b/client-go/informers/externalversions/api/interface.go index 210b89f8..10eef397 100644 --- a/client-go/informers/externalversions/api/interface.go +++ b/client-go/informers/externalversions/api/interface.go @@ -18,15 +18,12 @@ limitations under the License. package api import ( - v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api/v1alpha1" v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/api/v1alpha2" internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" ) // Interface provides access to each of this group's versions. type Interface interface { - // V1alpha1 provides access to shared informers for resources in V1alpha1. - V1alpha1() v1alpha1.Interface // V1alpha2 provides access to shared informers for resources in V1alpha2. V1alpha2() v1alpha2.Interface } @@ -42,11 +39,6 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} } -// V1alpha1 returns a new v1alpha1.Interface. -func (g *group) V1alpha1() v1alpha1.Interface { - return v1alpha1.New(g.factory, g.namespace, g.tweakListOptions) -} - // V1alpha2 returns a new v1alpha2.Interface. func (g *group) V1alpha2() v1alpha2.Interface { return v1alpha2.New(g.factory, g.namespace, g.tweakListOptions) diff --git a/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go b/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go deleted file mode 100644 index a1522e48..00000000 --- a/client-go/informers/externalversions/api/v1alpha1/inferencemodel.go +++ /dev/null @@ -1,89 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by informer-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - context "context" - time "time" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" - watch "k8s.io/apimachinery/pkg/watch" - cache "k8s.io/client-go/tools/cache" - gatewayapiinferenceextensionapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha1" -) - -// InferenceModelInformer provides access to a shared informer and lister for -// InferenceModels. -type InferenceModelInformer interface { - Informer() cache.SharedIndexInformer - Lister() apiv1alpha1.InferenceModelLister -} - -type inferenceModelInformer struct { - factory internalinterfaces.SharedInformerFactory - tweakListOptions internalinterfaces.TweakListOptionsFunc - namespace string -} - -// NewInferenceModelInformer constructs a new informer for InferenceModel type. -// Always prefer using an informer factory to get a shared informer instead of getting an independent -// one. This reduces memory footprint and number of connections to the server. -func NewInferenceModelInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { - return NewFilteredInferenceModelInformer(client, namespace, resyncPeriod, indexers, nil) -} - -// NewFilteredInferenceModelInformer constructs a new informer for InferenceModel type. -// Always prefer using an informer factory to get a shared informer instead of getting an independent -// one. This reduces memory footprint and number of connections to the server. -func NewFilteredInferenceModelInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { - return cache.NewSharedIndexInformer( - &cache.ListWatch{ - ListFunc: func(options v1.ListOptions) (runtime.Object, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.InferenceV1alpha1().InferenceModels(namespace).List(context.TODO(), options) - }, - WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.InferenceV1alpha1().InferenceModels(namespace).Watch(context.TODO(), options) - }, - }, - &gatewayapiinferenceextensionapiv1alpha1.InferenceModel{}, - resyncPeriod, - indexers, - ) -} - -func (f *inferenceModelInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { - return NewFilteredInferenceModelInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) -} - -func (f *inferenceModelInformer) Informer() cache.SharedIndexInformer { - return f.factory.InformerFor(&gatewayapiinferenceextensionapiv1alpha1.InferenceModel{}, f.defaultInformer) -} - -func (f *inferenceModelInformer) Lister() apiv1alpha1.InferenceModelLister { - return apiv1alpha1.NewInferenceModelLister(f.Informer().GetIndexer()) -} diff --git a/client-go/informers/externalversions/api/v1alpha1/inferencepool.go b/client-go/informers/externalversions/api/v1alpha1/inferencepool.go deleted file mode 100644 index 27f2d29e..00000000 --- a/client-go/informers/externalversions/api/v1alpha1/inferencepool.go +++ /dev/null @@ -1,89 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by informer-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - context "context" - time "time" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" - watch "k8s.io/apimachinery/pkg/watch" - cache "k8s.io/client-go/tools/cache" - gatewayapiinferenceextensionapiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" - versioned "sigs.k8s.io/gateway-api-inference-extension/client-go/clientset/versioned" - internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/client-go/listers/api/v1alpha1" -) - -// InferencePoolInformer provides access to a shared informer and lister for -// InferencePools. -type InferencePoolInformer interface { - Informer() cache.SharedIndexInformer - Lister() apiv1alpha1.InferencePoolLister -} - -type inferencePoolInformer struct { - factory internalinterfaces.SharedInformerFactory - tweakListOptions internalinterfaces.TweakListOptionsFunc - namespace string -} - -// NewInferencePoolInformer constructs a new informer for InferencePool type. -// Always prefer using an informer factory to get a shared informer instead of getting an independent -// one. This reduces memory footprint and number of connections to the server. -func NewInferencePoolInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { - return NewFilteredInferencePoolInformer(client, namespace, resyncPeriod, indexers, nil) -} - -// NewFilteredInferencePoolInformer constructs a new informer for InferencePool type. -// Always prefer using an informer factory to get a shared informer instead of getting an independent -// one. This reduces memory footprint and number of connections to the server. -func NewFilteredInferencePoolInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { - return cache.NewSharedIndexInformer( - &cache.ListWatch{ - ListFunc: func(options v1.ListOptions) (runtime.Object, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.InferenceV1alpha1().InferencePools(namespace).List(context.TODO(), options) - }, - WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { - if tweakListOptions != nil { - tweakListOptions(&options) - } - return client.InferenceV1alpha1().InferencePools(namespace).Watch(context.TODO(), options) - }, - }, - &gatewayapiinferenceextensionapiv1alpha1.InferencePool{}, - resyncPeriod, - indexers, - ) -} - -func (f *inferencePoolInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { - return NewFilteredInferencePoolInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) -} - -func (f *inferencePoolInformer) Informer() cache.SharedIndexInformer { - return f.factory.InformerFor(&gatewayapiinferenceextensionapiv1alpha1.InferencePool{}, f.defaultInformer) -} - -func (f *inferencePoolInformer) Lister() apiv1alpha1.InferencePoolLister { - return apiv1alpha1.NewInferencePoolLister(f.Informer().GetIndexer()) -} diff --git a/client-go/informers/externalversions/api/v1alpha1/interface.go b/client-go/informers/externalversions/api/v1alpha1/interface.go deleted file mode 100644 index 3ea6d988..00000000 --- a/client-go/informers/externalversions/api/v1alpha1/interface.go +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by informer-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - internalinterfaces "sigs.k8s.io/gateway-api-inference-extension/client-go/informers/externalversions/internalinterfaces" -) - -// Interface provides access to all the informers in this group version. -type Interface interface { - // InferenceModels returns a InferenceModelInformer. - InferenceModels() InferenceModelInformer - // InferencePools returns a InferencePoolInformer. - InferencePools() InferencePoolInformer -} - -type version struct { - factory internalinterfaces.SharedInformerFactory - namespace string - tweakListOptions internalinterfaces.TweakListOptionsFunc -} - -// New returns a new Interface. -func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { - return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} -} - -// InferenceModels returns a InferenceModelInformer. -func (v *version) InferenceModels() InferenceModelInformer { - return &inferenceModelInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} -} - -// InferencePools returns a InferencePoolInformer. -func (v *version) InferencePools() InferencePoolInformer { - return &inferencePoolInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} -} diff --git a/client-go/informers/externalversions/generic.go b/client-go/informers/externalversions/generic.go index 9f363d88..4186b2f6 100644 --- a/client-go/informers/externalversions/generic.go +++ b/client-go/informers/externalversions/generic.go @@ -22,7 +22,6 @@ import ( schema "k8s.io/apimachinery/pkg/runtime/schema" cache "k8s.io/client-go/tools/cache" - v1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" v1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -52,13 +51,7 @@ func (f *genericInformer) Lister() cache.GenericLister { // TODO extend this to unknown resources with a client pool func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { switch resource { - // Group=inference.networking.x-k8s.io, Version=v1alpha1 - case v1alpha1.SchemeGroupVersion.WithResource("inferencemodels"): - return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha1().InferenceModels().Informer()}, nil - case v1alpha1.SchemeGroupVersion.WithResource("inferencepools"): - return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha1().InferencePools().Informer()}, nil - - // Group=inference.networking.x-k8s.io, Version=v1alpha2 + // Group=inference.networking.x-k8s.io, Version=v1alpha2 case v1alpha2.SchemeGroupVersion.WithResource("inferencemodels"): return &genericInformer{resource: resource.GroupResource(), informer: f.Inference().V1alpha2().InferenceModels().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("inferencepools"): diff --git a/client-go/listers/api/v1alpha1/expansion_generated.go b/client-go/listers/api/v1alpha1/expansion_generated.go deleted file mode 100644 index ffbe67cf..00000000 --- a/client-go/listers/api/v1alpha1/expansion_generated.go +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by lister-gen. DO NOT EDIT. - -package v1alpha1 - -// InferenceModelListerExpansion allows custom methods to be added to -// InferenceModelLister. -type InferenceModelListerExpansion interface{} - -// InferenceModelNamespaceListerExpansion allows custom methods to be added to -// InferenceModelNamespaceLister. -type InferenceModelNamespaceListerExpansion interface{} - -// InferencePoolListerExpansion allows custom methods to be added to -// InferencePoolLister. -type InferencePoolListerExpansion interface{} - -// InferencePoolNamespaceListerExpansion allows custom methods to be added to -// InferencePoolNamespaceLister. -type InferencePoolNamespaceListerExpansion interface{} diff --git a/client-go/listers/api/v1alpha1/inferencemodel.go b/client-go/listers/api/v1alpha1/inferencemodel.go deleted file mode 100644 index b4342842..00000000 --- a/client-go/listers/api/v1alpha1/inferencemodel.go +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by lister-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - labels "k8s.io/apimachinery/pkg/labels" - listers "k8s.io/client-go/listers" - cache "k8s.io/client-go/tools/cache" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -// InferenceModelLister helps list InferenceModels. -// All objects returned here must be treated as read-only. -type InferenceModelLister interface { - // List lists all InferenceModels in the indexer. - // Objects returned here must be treated as read-only. - List(selector labels.Selector) (ret []*apiv1alpha1.InferenceModel, err error) - // InferenceModels returns an object that can list and get InferenceModels. - InferenceModels(namespace string) InferenceModelNamespaceLister - InferenceModelListerExpansion -} - -// inferenceModelLister implements the InferenceModelLister interface. -type inferenceModelLister struct { - listers.ResourceIndexer[*apiv1alpha1.InferenceModel] -} - -// NewInferenceModelLister returns a new InferenceModelLister. -func NewInferenceModelLister(indexer cache.Indexer) InferenceModelLister { - return &inferenceModelLister{listers.New[*apiv1alpha1.InferenceModel](indexer, apiv1alpha1.Resource("inferencemodel"))} -} - -// InferenceModels returns an object that can list and get InferenceModels. -func (s *inferenceModelLister) InferenceModels(namespace string) InferenceModelNamespaceLister { - return inferenceModelNamespaceLister{listers.NewNamespaced[*apiv1alpha1.InferenceModel](s.ResourceIndexer, namespace)} -} - -// InferenceModelNamespaceLister helps list and get InferenceModels. -// All objects returned here must be treated as read-only. -type InferenceModelNamespaceLister interface { - // List lists all InferenceModels in the indexer for a given namespace. - // Objects returned here must be treated as read-only. - List(selector labels.Selector) (ret []*apiv1alpha1.InferenceModel, err error) - // Get retrieves the InferenceModel from the indexer for a given namespace and name. - // Objects returned here must be treated as read-only. - Get(name string) (*apiv1alpha1.InferenceModel, error) - InferenceModelNamespaceListerExpansion -} - -// inferenceModelNamespaceLister implements the InferenceModelNamespaceLister -// interface. -type inferenceModelNamespaceLister struct { - listers.ResourceIndexer[*apiv1alpha1.InferenceModel] -} diff --git a/client-go/listers/api/v1alpha1/inferencepool.go b/client-go/listers/api/v1alpha1/inferencepool.go deleted file mode 100644 index 387daf39..00000000 --- a/client-go/listers/api/v1alpha1/inferencepool.go +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2024 The Kubernetes 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. -*/ -// Code generated by lister-gen. DO NOT EDIT. - -package v1alpha1 - -import ( - labels "k8s.io/apimachinery/pkg/labels" - listers "k8s.io/client-go/listers" - cache "k8s.io/client-go/tools/cache" - apiv1alpha1 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" -) - -// InferencePoolLister helps list InferencePools. -// All objects returned here must be treated as read-only. -type InferencePoolLister interface { - // List lists all InferencePools in the indexer. - // Objects returned here must be treated as read-only. - List(selector labels.Selector) (ret []*apiv1alpha1.InferencePool, err error) - // InferencePools returns an object that can list and get InferencePools. - InferencePools(namespace string) InferencePoolNamespaceLister - InferencePoolListerExpansion -} - -// inferencePoolLister implements the InferencePoolLister interface. -type inferencePoolLister struct { - listers.ResourceIndexer[*apiv1alpha1.InferencePool] -} - -// NewInferencePoolLister returns a new InferencePoolLister. -func NewInferencePoolLister(indexer cache.Indexer) InferencePoolLister { - return &inferencePoolLister{listers.New[*apiv1alpha1.InferencePool](indexer, apiv1alpha1.Resource("inferencepool"))} -} - -// InferencePools returns an object that can list and get InferencePools. -func (s *inferencePoolLister) InferencePools(namespace string) InferencePoolNamespaceLister { - return inferencePoolNamespaceLister{listers.NewNamespaced[*apiv1alpha1.InferencePool](s.ResourceIndexer, namespace)} -} - -// InferencePoolNamespaceLister helps list and get InferencePools. -// All objects returned here must be treated as read-only. -type InferencePoolNamespaceLister interface { - // List lists all InferencePools in the indexer for a given namespace. - // Objects returned here must be treated as read-only. - List(selector labels.Selector) (ret []*apiv1alpha1.InferencePool, err error) - // Get retrieves the InferencePool from the indexer for a given namespace and name. - // Objects returned here must be treated as read-only. - Get(name string) (*apiv1alpha1.InferencePool, error) - InferencePoolNamespaceListerExpansion -} - -// inferencePoolNamespaceLister implements the InferencePoolNamespaceLister -// interface. -type inferencePoolNamespaceLister struct { - listers.ResourceIndexer[*apiv1alpha1.InferencePool] -} diff --git a/cmd/epp/main.go b/cmd/epp/main.go index b66024ec..ab270c49 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -39,7 +39,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" @@ -104,9 +103,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha1.AddToScheme(scheme)) utilruntime.Must(v1alpha2.AddToScheme(scheme)) - } func main() { diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml index 09258c20..2995e863 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml @@ -14,230 +14,6 @@ spec: singular: inferencemodel scope: Namespaced versions: - - name: v1alpha1 - schema: - openAPIV3Schema: - description: InferenceModel is the Schema for the InferenceModels API. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: |- - InferenceModelSpec represents the desired state of a specific model use case. This resource is - managed by the "Inference Workload Owner" persona. - - The Inference Workload Owner persona is someone that trains, verifies, and - leverages a large language model from a model frontend, drives the lifecycle - and rollout of new versions of those models, and defines the specific - performance and latency goals for the model. These workloads are - expected to operate within an InferencePool sharing compute capacity with other - InferenceModels, defined by the Inference Platform Admin. - - InferenceModel's modelName (not the ObjectMeta name) is unique for a given InferencePool, - if the name is reused, an error will be shown on the status of a - InferenceModel that attempted to reuse. The oldest InferenceModel, based on - creation timestamp, will be selected to remain valid. In the event of a race - condition, one will be selected at random. - properties: - criticality: - description: |- - Criticality defines how important it is to serve the model compared to other models referencing the same pool. - Criticality impacts how traffic is handled in resource constrained situations. It handles this by - queuing or rejecting requests of lower criticality. InferenceModels of an equivalent Criticality will - fairly share resources over throughput of tokens. In the future, the metric used to calculate fairness, - and the proportionality of fairness will be configurable. - - Default values for this field will not be set, to allow for future additions of new field that may 'one of' with this field. - Any implementations that may consume this field may treat an unset value as the 'Standard' range. - enum: - - Critical - - Standard - - Sheddable - type: string - modelName: - description: |- - ModelName is the name of the model as it will be set in the "model" parameter for an incoming request. - ModelNames must be unique for a referencing InferencePool - (names can be reused for a different pool in the same cluster). - The modelName with the oldest creation timestamp is retained, and the incoming - InferenceModel is sets the Ready status to false with a corresponding reason. - In the rare case of a race condition, one Model will be selected randomly to be considered valid, and the other rejected. - Names can be reserved without an underlying model configured in the pool. - This can be done by specifying a target model and setting the weight to zero, - an error will be returned specifying that no valid target model is found. - maxLength: 256 - type: string - poolRef: - description: PoolRef is a reference to the inference pool, the pool - must exist in the same namespace. - properties: - group: - default: inference.networking.x-k8s.io - description: Group is the group of the referent. - maxLength: 253 - pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ - type: string - kind: - default: InferencePool - description: Kind is kind of the referent. For example "InferencePool". - maxLength: 63 - minLength: 1 - pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ - type: string - name: - description: Name is the name of the referent. - maxLength: 253 - minLength: 1 - type: string - required: - - name - type: object - targetModels: - description: |- - TargetModels allow multiple versions of a model for traffic splitting. - If not specified, the target model name is defaulted to the modelName parameter. - modelName is often in reference to a LoRA adapter. - items: - description: |- - TargetModel represents a deployed model or a LoRA adapter. The - Name field is expected to match the name of the LoRA adapter - (or base model) as it is registered within the model server. Inference - Gateway assumes that the model exists on the model server and it's the - responsibility of the user to validate a correct match. Should a model fail - to exist at request time, the error is processed by the Inference Gateway - and emitted on the appropriate InferenceModel object. - properties: - name: - description: Name is the name of the adapter or base model, - as expected by the ModelServer. - maxLength: 253 - type: string - weight: - description: |- - Weight is used to determine the proportion of traffic that should be - sent to this model when multiple target models are specified. - - Weight defines the proportion of requests forwarded to the specified - model. This is computed as weight/(sum of all weights in this - TargetModels list). For non-zero values, there may be some epsilon from - the exact proportion defined here depending on the precision an - implementation supports. Weight is not a percentage and the sum of - weights does not need to equal 100. - - If a weight is set for any targetModel, it must be set for all targetModels. - Conversely weights are optional, so long as ALL targetModels do not specify a weight. - format: int32 - maximum: 1000000 - minimum: 0 - type: integer - required: - - name - type: object - maxItems: 10 - type: array - x-kubernetes-validations: - - message: Weights should be set for all models, or none of the models. - rule: self.all(model, has(model.weight)) || self.all(model, !has(model.weight)) - required: - - modelName - - poolRef - type: object - status: - description: InferenceModelStatus defines the observed state of InferenceModel - properties: - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Ready - description: |- - Conditions track the state of the InferenceModel. - - Known condition types are: - - * "Accepted" - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - type: object - type: object - served: true - storage: false - subresources: - status: {} - name: v1alpha2 schema: openAPIV3Schema: diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml index 918e95cb..8a7ad938 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml @@ -14,196 +14,6 @@ spec: singular: inferencepool scope: Namespaced versions: - - name: v1alpha1 - schema: - openAPIV3Schema: - description: InferencePool is the Schema for the InferencePools API. - properties: - apiVersion: - description: |- - APIVersion defines the versioned schema of this representation of an object. - Servers should convert recognized schemas to the latest internal value, and - may reject unrecognized values. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources - type: string - kind: - description: |- - Kind is a string value representing the REST resource this object represents. - Servers may infer this from the endpoint the client submits requests to. - Cannot be updated. - In CamelCase. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - metadata: - type: object - spec: - description: InferencePoolSpec defines the desired state of InferencePool - properties: - extensionRef: - description: Extension configures an endpoint picker as an extension - service. - properties: - failureMode: - default: FailClose - description: |- - Configures how the gateway handles the case when the extension is not responsive. - Defaults to failClose. - enum: - - FailOpen - - FailClose - type: string - group: - default: "" - description: |- - Group is the group of the referent. - When unspecified or empty string, core API group is inferred. - type: string - kind: - default: Service - description: |- - Kind is the Kubernetes resource kind of the referent. For example - "Service". - - Defaults to "Service" when not specified. - - ExternalName services can refer to CNAME DNS records that may live - outside of the cluster and as such are difficult to reason about in - terms of conformance. They also may not be safe to forward to (see - CVE-2021-25740 for more information). Implementations MUST NOT - support ExternalName Services. - type: string - name: - description: Name is the name of the referent. - type: string - targetPortNumber: - description: |- - The port number on the pods running the extension. When unspecified, implementations SHOULD infer a - default value of 9002 when the Kind is Service. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - name - type: object - selector: - additionalProperties: - description: |- - LabelValue is the value of a label. This is used for validation - of maps. This matches the Kubernetes label validation rules: - * must be 63 characters or less (can be empty), - * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), - * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. - - Valid values include: - - * MyValue - * my.name - * 123-my-value - maxLength: 63 - minLength: 0 - pattern: ^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$ - type: string - description: |- - Selector defines a map of labels to watch model server pods - that should be included in the InferencePool. - In some cases, implementations may translate this field to a Service selector, so this matches the simple - map used for Service selectors instead of the full Kubernetes LabelSelector type. - type: object - targetPortNumber: - description: |- - TargetPortNumber defines the port number to access the selected model servers. - The number must be in the range 1 to 65535. - format: int32 - maximum: 65535 - minimum: 1 - type: integer - required: - - extensionRef - - selector - - targetPortNumber - type: object - status: - description: InferencePoolStatus defines the observed state of InferencePool - properties: - conditions: - default: - - lastTransitionTime: "1970-01-01T00:00:00Z" - message: Waiting for controller - reason: Pending - status: Unknown - type: Ready - description: |- - Conditions track the state of the InferencePool. - - Known condition types are: - - * "Ready" - items: - description: Condition contains details for one aspect of the current - state of this API Resource. - properties: - lastTransitionTime: - description: |- - lastTransitionTime is the last time the condition transitioned from one status to another. - This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. - format: date-time - type: string - message: - description: |- - message is a human readable message indicating details about the transition. - This may be an empty string. - maxLength: 32768 - type: string - observedGeneration: - description: |- - observedGeneration represents the .metadata.generation that the condition was set based upon. - For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date - with respect to the current state of the instance. - format: int64 - minimum: 0 - type: integer - reason: - description: |- - reason contains a programmatic identifier indicating the reason for the condition's last transition. - Producers of specific condition types may define expected values and meanings for this field, - and whether the values are considered a guaranteed API. - The value should be a CamelCase string. - This field may not be empty. - maxLength: 1024 - minLength: 1 - pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ - type: string - status: - description: status of the condition, one of True, False, Unknown. - enum: - - "True" - - "False" - - Unknown - type: string - type: - description: type of condition in CamelCase or in foo.example.com/CamelCase. - maxLength: 316 - pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ - type: string - required: - - lastTransitionTime - - message - - reason - - status - - type - type: object - maxItems: 8 - type: array - x-kubernetes-list-map-keys: - - type - x-kubernetes-list-type: map - type: object - type: object - served: true - storage: false - subresources: - status: {} - name: v1alpha2 schema: openAPIV3Schema: @@ -299,6 +109,8 @@ spec: that should be included in the InferencePool. In some cases, implementations may translate this field to a Service selector, so this matches the simple map used for Service selectors instead of the full Kubernetes LabelSelector type. + If sepecified, it will be applied to match the model server pods in the same namespace as the InferencePool. + Cross namesoace selector is not supported. type: object targetPortNumber: description: |- From 7e08e07614ed458d4cdceefe259eb799651794fe Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 27 Feb 2025 21:06:32 +0000 Subject: [PATCH 059/260] removed the EndpointPickerNotHealthy condition form pool status (#421) --- api/v1alpha2/inferencepool_types.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index 0781f044..2300f52a 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -232,10 +232,6 @@ const ( // // * "Ready" // - // Possible reasons for this condition to be False are: - // - // * "EndpointPickerNotHealthy" - // // Possible reasons for this condition to be Unknown are: // // * "Pending" @@ -245,9 +241,6 @@ const ( // PoolReasonReady is the desired state. The pool and its components are initialized and ready for traffic. PoolReasonReady InferencePoolConditionReason = "Ready" - // PoolReasonEPPNotHealthy is used when the EPP has not yet passed health checks, or has started failing them. - PoolReasonEPPNotHealthy InferencePoolConditionReason = "EndpointPickerNotHealthy" - // PoolReasonPending is the initial state, and indicates that the controller has not yet reconciled this pool. PoolReasonPending InferencePoolConditionReason = "Pending" ) From 29bf32dfee0ae6558c0da532555448da4db93414 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Thu, 27 Feb 2025 16:34:30 -0500 Subject: [PATCH 060/260] Add metrics validation in integration test (#413) Start by adding request total metrics, more validation will be added in follow up. https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/326 --- test/integration/hermetic_test.go | 82 +++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 2ea66dba..b4355539 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -24,8 +24,12 @@ import ( "errors" "fmt" "io" + "net" + "net/http" "os" "path/filepath" + "strconv" + "strings" "testing" "time" @@ -33,6 +37,7 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/stretchr/testify/assert" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -43,12 +48,16 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8syaml "k8s.io/apimachinery/pkg/util/yaml" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/component-base/metrics/legacyregistry" + metricsutils "k8s.io/component-base/metrics/testutil" ctrl "sigs.k8s.io/controller-runtime" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -57,7 +66,8 @@ import ( ) const ( - port = runserver.DefaultGrpcPort + port = runserver.DefaultGrpcPort + metricsPort = 8888 ) var ( @@ -76,6 +86,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { wantHeaders []*configPb.HeaderValueOption wantMetadata *structpb.Struct wantBody []byte + wantMetrics string wantErr bool immediateResponse *extProcPb.ImmediateResponse }{ @@ -113,7 +124,12 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, wantMetadata: makeMetadata("address-1:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), - wantErr: false, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 + `, + wantErr: false, }, { name: "select active lora, low queue", @@ -161,7 +177,12 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, wantMetadata: makeMetadata("address-1:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), - wantErr: false, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `, + wantErr: false, }, { name: "select no lora despite active model, avoid excessive queue size", @@ -210,7 +231,12 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, wantMetadata: makeMetadata("address-2:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), - wantErr: false, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `, + wantErr: false, }, { name: "noncritical and all models past threshold, shed request", @@ -253,6 +279,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { Code: envoyTypePb.StatusCode_TooManyRequests, }, }, + wantMetrics: "", }, { name: "noncritical, but one server has capacity, do not shed", @@ -301,7 +328,12 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, wantMetadata: makeMetadata("address-0:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), - wantErr: false, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 + `, + wantErr: false, }, } @@ -345,6 +377,14 @@ func TestKubeInferenceModelRequest(t *testing.T) { if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { t.Errorf("Unexpected response, (-want +got): %v", diff) } + + if test.wantMetrics != "" { + if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(test.wantMetrics), "inference_model_request_total"); err != nil { + t.Error(err) + } + } + + legacyregistry.Reset() }) } } @@ -424,6 +464,10 @@ func BeforeSuit(t *testing.T) func() { logutil.Fatal(logger, err, "Failed to create controller manager") } + if err := registerMetricsHandler(mgr, metricsPort); err != nil { + logutil.Fatal(logger, err, "Failed to register metrics handler") + } + serverRunner = runserver.NewDefaultExtProcServerRunner() // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" @@ -544,3 +588,31 @@ func makeMetadata(endpoint string) *structpb.Struct { }, } } + +// registerMetricsHandler is a simplified version of metrics endpoint handler +// without Authentication for integration tests. +func registerMetricsHandler(mgr manager.Manager, port int) error { + metrics.Register() + + // Init HTTP server. + h := promhttp.HandlerFor( + legacyregistry.DefaultGatherer, + promhttp.HandlerOpts{}, + ) + + mux := http.NewServeMux() + mux.Handle("/metrics", h) + + srv := &http.Server{ + Addr: net.JoinHostPort("", strconv.Itoa(port)), + Handler: mux, + } + + if err := mgr.Add(&manager.Server{ + Name: "metrics", + Server: srv, + }); err != nil { + return err + } + return nil +} From d2c6e7a728c5d3bfe12dbb26e43bc27995962651 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 28 Feb 2025 00:18:29 +0200 Subject: [PATCH 061/260] predicate follow up PR to remove the check from Reconcile func (#418) * predicate follow up PR to remove the check from Reconcile func Signed-off-by: Nir Rozenbaum * removed irrelevant test after introducing predicate Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/controller/inferencemodel_reconciler.go | 5 +---- pkg/epp/controller/inferencemodel_reconciler_test.go | 11 ----------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index 7cf18808..ebdb1cdd 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -43,10 +43,7 @@ type InferenceModelReconciler struct { } func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - if req.Namespace != c.PoolNamespacedName.Namespace { - return ctrl.Result{}, nil - } - logger := log.FromContext(ctx).V(logutil.DEFAULT).WithValues("inferenceModel", req.Name) + logger := log.FromContext(ctx).V(logutil.DEFAULT).WithValues("inferenceModel", req.NamespacedName) ctx = ctrl.LoggerInto(ctx, logger) logger.Info("Reconciling InferenceModel") diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index 87323e80..d5277919 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -85,11 +85,6 @@ var ( ModelName("fake model2"). CreationTimestamp(metav1.Unix(1000, 0)). PoolName(pool.Name).ObjRef() - infModel2NS2 = utiltest.MakeInferenceModel(infModel2.Name). - Namespace("ns2"). - ModelName(infModel2.Spec.ModelName). - CreationTimestamp(metav1.Unix(1000, 0)). - PoolName(pool.Name).ObjRef() ) func TestInferenceModelReconciler(t *testing.T) { @@ -131,12 +126,6 @@ func TestInferenceModelReconciler(t *testing.T) { model: infModel1NS2, wantModels: []*v1alpha2.InferenceModel{infModel1}, }, - { - name: "Model referencing a different pool, same pool name but different namespace", - modelsInStore: []*v1alpha2.InferenceModel{infModel1}, - model: infModel2NS2, - wantModels: []*v1alpha2.InferenceModel{infModel1}, - }, { name: "Existing model changed pools, replaced with another", modelsInStore: []*v1alpha2.InferenceModel{infModel1}, From 5137c591daf5dd553882c28052270edfe8c203cd Mon Sep 17 00:00:00 2001 From: Tiger Xu / Zhonghu Xu Date: Fri, 28 Feb 2025 23:06:57 +0800 Subject: [PATCH 062/260] Mis cleanup (#428) --- .../controller/inferencemodel_reconciler.go | 8 +++--- pkg/epp/datastore/datastore.go | 26 +++++++++---------- pkg/epp/datastore/datastore_test.go | 10 +++---- pkg/epp/handlers/request.go | 4 +-- test/integration/hermetic_test.go | 4 +-- 5 files changed, 25 insertions(+), 27 deletions(-) diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index ebdb1cdd..2b50537a 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -68,9 +68,9 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque logger = logger.WithValues("poolRef", infModel.Spec.PoolRef).WithValues("modelName", infModel.Spec.ModelName) if !c.Datastore.ModelSetIfOlder(infModel) { logger.Info("Skipping InferenceModel, existing instance has older creation timestamp") - + } else { + logger.Info("Added/Updated InferenceModel") } - logger.Info("Added/Updated InferenceModel") return ctrl.Result{}, nil } @@ -82,8 +82,8 @@ func (c *InferenceModelReconciler) handleModelDeleted(ctx context.Context, req t // other instances referencing the same modelName if exist, and store the oldest in // its place. This ensures that the InferenceModel with the oldest creation // timestamp is active. - existing, exists := c.Datastore.ModelDelete(req) - if !exists { + existing := c.Datastore.ModelDelete(req) + if existing == nil { // No entry exists in the first place, nothing to do. return nil } diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index cd5d290f..eee17ed4 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -51,15 +51,15 @@ type Datastore interface { // InferenceModel operations ModelSetIfOlder(infModel *v1alpha2.InferenceModel) bool - ModelGet(modelName string) (*v1alpha2.InferenceModel, bool) - ModelDelete(namespacedName types.NamespacedName) (*v1alpha2.InferenceModel, bool) + ModelGet(modelName string) *v1alpha2.InferenceModel + ModelDelete(namespacedName types.NamespacedName) *v1alpha2.InferenceModel ModelResync(ctx context.Context, ctrlClient client.Client, modelName string) (bool, error) ModelGetAll() []*v1alpha2.InferenceModel // PodMetrics operations PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool PodUpdateMetricsIfExist(namespacedName types.NamespacedName, m *Metrics) bool - PodGet(namespacedName types.NamespacedName) (*PodMetrics, bool) + PodGet(namespacedName types.NamespacedName) *PodMetrics PodDelete(namespacedName types.NamespacedName) PodResyncAll(ctx context.Context, ctrlClient client.Client) PodGetAll() []*PodMetrics @@ -147,7 +147,6 @@ func (ds *datastore) PoolLabelsMatch(podLabels map[string]string) bool { return poolSelector.Matches(podSet) } -// /// InferenceModel APIs /// func (ds *datastore) ModelSetIfOlder(infModel *v1alpha2.InferenceModel) bool { ds.poolAndModelsMu.Lock() defer ds.poolAndModelsMu.Unlock() @@ -199,23 +198,22 @@ func (ds *datastore) ModelResync(ctx context.Context, c client.Client, modelName return true, nil } -func (ds *datastore) ModelGet(modelName string) (*v1alpha2.InferenceModel, bool) { +func (ds *datastore) ModelGet(modelName string) *v1alpha2.InferenceModel { ds.poolAndModelsMu.RLock() defer ds.poolAndModelsMu.RUnlock() - m, exists := ds.models[modelName] - return m, exists + return ds.models[modelName] } -func (ds *datastore) ModelDelete(namespacedName types.NamespacedName) (*v1alpha2.InferenceModel, bool) { +func (ds *datastore) ModelDelete(namespacedName types.NamespacedName) *v1alpha2.InferenceModel { ds.poolAndModelsMu.Lock() defer ds.poolAndModelsMu.Unlock() for _, m := range ds.models { if m.Name == namespacedName.Name && m.Namespace == namespacedName.Namespace { delete(ds.models, m.Spec.ModelName) - return m, true + return m } } - return nil, false + return nil } func (ds *datastore) ModelGetAll() []*v1alpha2.InferenceModel { @@ -238,12 +236,12 @@ func (ds *datastore) PodUpdateMetricsIfExist(namespacedName types.NamespacedName return false } -func (ds *datastore) PodGet(namespacedName types.NamespacedName) (*PodMetrics, bool) { +func (ds *datastore) PodGet(namespacedName types.NamespacedName) *PodMetrics { val, ok := ds.pods.Load(namespacedName) if ok { - return val.(*PodMetrics), true + return val.(*PodMetrics) } - return nil, false + return nil } func (ds *datastore) PodGetAll() []*PodMetrics { @@ -311,7 +309,7 @@ func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client) } } - // Remove pods that don't exist or not ready any more. + // Remove pods that don't belong to the pool or not ready any more. deleteFn := func(k, v any) bool { pm := v.(*PodMetrics) if exist := activePods[pm.NamespacedName.Name]; !exist { diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index edc96626..95ac642c 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -176,8 +176,8 @@ func TestModel(t *testing.T) { name: "Getting by model name, chat -> model2", existingModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, op: func(ds Datastore) bool { - gotChat, exists := ds.ModelGet(chatModel) - return exists && cmp.Diff(model2chat, gotChat) == "" + gotChat := ds.ModelGet(chatModel) + return gotChat != nil && cmp.Diff(model2chat, gotChat) == "" }, wantOpResult: true, wantModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, @@ -186,9 +186,9 @@ func TestModel(t *testing.T) { name: "Delete the model", existingModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, op: func(ds Datastore) bool { - _, existed := ds.ModelDelete(types.NamespacedName{Name: model1ts.Name, Namespace: model1ts.Namespace}) - _, exists := ds.ModelGet(tsModel) - return existed && !exists + existing := ds.ModelDelete(types.NamespacedName{Name: model1ts.Name, Namespace: model1ts.Namespace}) + got := ds.ModelGet(tsModel) + return existing != nil && got == nil }, wantOpResult: true, diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index c6cfdda2..20271913 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -64,8 +64,8 @@ func (s *Server) HandleRequestBody( // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. // This might be a security risk in the future where adapters not registered in the InferenceModel // are able to be requested by using their distinct name. - modelObj, exist := s.datastore.ModelGet(model) - if !exist { + modelObj := s.datastore.ModelGet(model) + if modelObj == nil { return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} } if len(modelObj.Spec.TargetModels) > 0 { diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index b4355539..de32dce0 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -520,8 +520,8 @@ func BeforeSuit(t *testing.T) func() { } assert.EventuallyWithT(t, func(t *assert.CollectT) { - _, modelExist := serverRunner.Datastore.ModelGet("my-model") - synced := serverRunner.Datastore.PoolHasSynced() && modelExist + modelExist := serverRunner.Datastore.ModelGet("my-model") + synced := serverRunner.Datastore.PoolHasSynced() && modelExist != nil assert.True(t, synced, "Timeout waiting for the pool and models to sync") }, 10*time.Second, 10*time.Millisecond) From 0d08a07b8e9cc9da6f6e197f4223872f332db7f1 Mon Sep 17 00:00:00 2001 From: Kuromesi Date: Fri, 28 Feb 2025 23:20:56 +0800 Subject: [PATCH 063/260] fix metric scrape port not updated when inference pool target port updated (#417) * fix metric scrape port not updated when inference pool target port updated Signed-off-by: Kuromesi * bug fix Signed-off-by: Kuromesi * fix ut Signed-off-by: Kuromesi * add log Signed-off-by: Kuromesi --------- Signed-off-by: Kuromesi --- pkg/epp/backend/fake.go | 2 +- pkg/epp/backend/provider.go | 13 +++++++++---- pkg/epp/backend/provider_test.go | 9 ++++++++- pkg/epp/backend/vllm/metrics.go | 4 +++- pkg/epp/controller/pod_reconciler_test.go | 8 ++++---- pkg/epp/datastore/datastore.go | 5 +---- pkg/epp/datastore/types.go | 11 +---------- 7 files changed, 27 insertions(+), 25 deletions(-) diff --git a/pkg/epp/backend/fake.go b/pkg/epp/backend/fake.go index 06f14f69..584486c2 100644 --- a/pkg/epp/backend/fake.go +++ b/pkg/epp/backend/fake.go @@ -31,7 +31,7 @@ type FakePodMetricsClient struct { Res map[types.NamespacedName]*datastore.PodMetrics } -func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *datastore.PodMetrics) (*datastore.PodMetrics, error) { +func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *datastore.PodMetrics, port int32) (*datastore.PodMetrics, error) { if err, ok := f.Err[existing.NamespacedName]; ok { return nil, err } diff --git a/pkg/epp/backend/provider.go b/pkg/epp/backend/provider.go index a12f84d5..959f3e0c 100644 --- a/pkg/epp/backend/provider.go +++ b/pkg/epp/backend/provider.go @@ -49,7 +49,7 @@ type Provider struct { } type PodMetricsClient interface { - FetchMetrics(ctx context.Context, existing *datastore.PodMetrics) (*datastore.PodMetrics, error) + FetchMetrics(ctx context.Context, existing *datastore.PodMetrics, port int32) (*datastore.PodMetrics, error) } func (p *Provider) Init(ctx context.Context, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { @@ -105,6 +105,11 @@ func (p *Provider) Init(ctx context.Context, refreshMetricsInterval, refreshProm func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { loggerTrace := logger.V(logutil.TRACE) + pool, _ := p.datastore.PoolGet() + if pool == nil { + loggerTrace.Info("No inference pool or not initialized") + return nil + } ctx, cancel := context.WithTimeout(context.Background(), fetchMetricsTimeout) defer cancel() start := time.Now() @@ -113,6 +118,7 @@ func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { // TODO: add a metric instead of logging loggerTrace.Info("Metrics refreshed", "duration", d) }() + var wg sync.WaitGroup errCh := make(chan error) processOnePod := func(key, value any) bool { @@ -121,7 +127,7 @@ func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { wg.Add(1) go func() { defer wg.Done() - updated, err := p.pmc.FetchMetrics(ctx, existing) + updated, err := p.pmc.FetchMetrics(ctx, existing, pool.Spec.TargetPortNumber) if err != nil { errCh <- fmt.Errorf("failed to parse metrics from %s: %v", existing.NamespacedName, err) return @@ -151,8 +157,6 @@ func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { } func (p *Provider) flushPrometheusMetricsOnce(logger logr.Logger) { - logger.V(logutil.DEBUG).Info("Flushing Prometheus Metrics") - pool, _ := p.datastore.PoolGet() if pool == nil { // No inference pool or not initialize. @@ -163,6 +167,7 @@ func (p *Provider) flushPrometheusMetricsOnce(logger logr.Logger) { var queueTotal int podMetrics := p.datastore.PodGetAll() + logger.V(logutil.VERBOSE).Info("Flushing Prometheus Metrics", "ReadyPods", len(podMetrics)) if len(podMetrics) == 0 { return } diff --git a/pkg/epp/backend/provider_test.go b/pkg/epp/backend/provider_test.go index f2db09fe..12994723 100644 --- a/pkg/epp/backend/provider_test.go +++ b/pkg/epp/backend/provider_test.go @@ -26,6 +26,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" ) @@ -68,6 +69,12 @@ var ( }, }, } + + inferencePool = &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ + TargetPortNumber: 8000, + }, + } ) func TestProvider(t *testing.T) { @@ -127,7 +134,7 @@ func TestProvider(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ds := datastore.NewFakeDatastore(test.storePods, nil, nil) + ds := datastore.NewFakeDatastore(test.storePods, nil, inferencePool) p := NewProvider(test.pmc, ds) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/pkg/epp/backend/vllm/metrics.go b/pkg/epp/backend/vllm/metrics.go index 8648e24c..4973c93e 100644 --- a/pkg/epp/backend/vllm/metrics.go +++ b/pkg/epp/backend/vllm/metrics.go @@ -55,13 +55,15 @@ type PodMetricsClientImpl struct{} func (p *PodMetricsClientImpl) FetchMetrics( ctx context.Context, existing *datastore.PodMetrics, + port int32, ) (*datastore.PodMetrics, error) { logger := log.FromContext(ctx) loggerDefault := logger.V(logutil.DEFAULT) // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. - url := existing.BuildScrapeEndpoint() + url := "http://" + existing.Address + ":" + strconv.Itoa(int(port)) + "/metrics" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { loggerDefault.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) diff --git a/pkg/epp/controller/pod_reconciler_test.go b/pkg/epp/controller/pod_reconciler_test.go index 57576213..7534ac0f 100644 --- a/pkg/epp/controller/pod_reconciler_test.go +++ b/pkg/epp/controller/pod_reconciler_test.go @@ -35,10 +35,10 @@ import ( ) var ( - basePod1 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1", ScrapePath: "/metrics", ScrapePort: 8000}} - basePod2 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2", ScrapePath: "/metrics", ScrapePort: 8000}} - basePod3 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3", ScrapePath: "/metrics", ScrapePort: 8000}} - basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11", ScrapePath: "/metrics", ScrapePort: 8000}} + basePod1 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1"}} + basePod2 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2"}} + basePod3 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3"}} + basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11"}} ) func TestPodReconciler(t *testing.T) { diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index eee17ed4..2994d6e1 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -263,16 +263,13 @@ func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { } func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool { - pool, _ := ds.PoolGet() new := &PodMetrics{ Pod: Pod{ NamespacedName: types.NamespacedName{ Name: pod.Name, Namespace: pod.Namespace, }, - Address: pod.Status.PodIP, - ScrapePath: "/metrics", - ScrapePort: pool.Spec.TargetPortNumber, + Address: pod.Status.PodIP, }, Metrics: Metrics{ ActiveModels: make(map[string]int), diff --git a/pkg/epp/datastore/types.go b/pkg/epp/datastore/types.go index 237e98ca..8cfcf1d1 100644 --- a/pkg/epp/datastore/types.go +++ b/pkg/epp/datastore/types.go @@ -26,10 +26,6 @@ import ( type Pod struct { NamespacedName types.NamespacedName Address string - - // metrics scrape options - ScrapePort int32 - ScrapePath string } type Metrics struct { @@ -61,11 +57,10 @@ func (pm *PodMetrics) Clone() *PodMetrics { Pod: Pod{ NamespacedName: pm.NamespacedName, Address: pm.Address, - ScrapePort: pm.ScrapePort, - ScrapePath: pm.ScrapePath, }, Metrics: Metrics{ ActiveModels: cm, + MaxActiveModels: pm.MaxActiveModels, RunningQueueSize: pm.RunningQueueSize, WaitingQueueSize: pm.WaitingQueueSize, KVCacheUsagePercent: pm.KVCacheUsagePercent, @@ -74,7 +69,3 @@ func (pm *PodMetrics) Clone() *PodMetrics { } return clone } - -func (pm *PodMetrics) BuildScrapeEndpoint() string { - return fmt.Sprintf("http://%s:%d%s", pm.Address, pm.ScrapePort, pm.ScrapePath) -} From b1fed6c98b09cea9754a739af7d91909645df753 Mon Sep 17 00:00:00 2001 From: Tiger Xu / Zhonghu Xu Date: Fri, 28 Feb 2025 23:21:03 +0800 Subject: [PATCH 064/260] make ModelName immutable and fix model weight (#427) * make ModelName immutable and fix model weight * Fix ut --- api/v1alpha2/inferencemodel_types.go | 3 ++- ...e.networking.x-k8s.io_inferencemodels.yaml | 5 ++++- pkg/epp/datastore/datastore.go | 11 ++++++++-- pkg/epp/datastore/datastore_test.go | 21 ++++++++++++++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/api/v1alpha2/inferencemodel_types.go b/api/v1alpha2/inferencemodel_types.go index 9ab1fd86..a75fd699 100644 --- a/api/v1alpha2/inferencemodel_types.go +++ b/api/v1alpha2/inferencemodel_types.go @@ -71,6 +71,7 @@ type InferenceModelSpec struct { // // +kubebuilder:validation:MaxLength=256 // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="modelName is immutable" ModelName string `json:"modelName"` // Criticality defines how important it is to serve the model compared to other models referencing the same pool. @@ -175,7 +176,7 @@ type TargetModel struct { // Conversely weights are optional, so long as ALL targetModels do not specify a weight. // // +optional - // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Maximum=1000000 Weight *int32 `json:"weight,omitempty"` } diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml index 2995e863..63c7fb51 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml @@ -82,6 +82,9 @@ spec: an error will be returned specifying that no valid target model is found. maxLength: 256 type: string + x-kubernetes-validations: + - message: modelName is immutable + rule: self == oldSelf poolRef: description: PoolRef is a reference to the inference pool, the pool must exist in the same namespace. @@ -143,7 +146,7 @@ spec: Conversely weights are optional, so long as ALL targetModels do not specify a weight. format: int32 maximum: 1000000 - minimum: 0 + minimum: 1 type: integer required: - name diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index 2994d6e1..f8d4722a 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -334,18 +334,25 @@ func stripLabelKeyAliasFromLabelMap(labels map[v1alpha2.LabelKey]v1alpha2.LabelV } func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed int64) string { - var weights int32 - source := rand.NewSource(rand.Int63()) if seed > 0 { source = rand.NewSource(seed) } r := rand.New(source) + + // all the weight values are nil, then we should return random model name + if model.Spec.TargetModels[0].Weight == nil { + index := r.Int31n(int32(len(model.Spec.TargetModels))) + return model.Spec.TargetModels[index].Name + } + + var weights int32 for _, model := range model.Spec.TargetModels { weights += *model.Weight } logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) randomVal := r.Int31n(weights) + // TODO: optimize this without using loop for _, model := range model.Spec.TargetModels { if randomVal < *model.Weight { return model.Name diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index 95ac642c..8fb269bc 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -280,6 +280,25 @@ func TestRandomWeightedDraw(t *testing.T) { }, want: "v1.1", }, + { + name: "weighted distribution with weight unset", + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ + { + Name: "canary", + }, + { + Name: "v1.1", + }, + { + Name: "v1", + }, + }, + }, + }, + want: "canary", + }, } var seedVal int64 = 420 for _, test := range tests { @@ -287,7 +306,7 @@ func TestRandomWeightedDraw(t *testing.T) { for range 10000 { model := RandomWeightedDraw(logger, test.model, seedVal) if model != test.want { - t.Errorf("Model returned!: %v", model) + t.Errorf("Model returned: %v != %v", model, test.want) break } } From 14afcd9461a57116b75df19e671f0a7033ff9c65 Mon Sep 17 00:00:00 2001 From: Rob Scott Date: Fri, 28 Feb 2025 18:26:55 -0800 Subject: [PATCH 065/260] Consistent validation for reference types (#430) * Consistent validation for reference types * Code updates after API type changes * Moving Label types to shared_types.go --- api/v1alpha2/inferencemodel_types.go | 13 +-- api/v1alpha2/inferencepool_types.go | 58 ++-------- api/v1alpha2/shared_types.go | 108 ++++++++++++++++++ api/v1alpha2/zz_generated.deepcopy.go | 6 +- .../api/v1alpha2/extension.go | 8 +- .../api/v1alpha2/extensionreference.go | 20 ++-- .../api/v1alpha2/poolobjectreference.go | 16 ++- ...ce.networking.x-k8s.io_inferencepools.yaml | 16 ++- .../controller/inferencemodel_reconciler.go | 4 +- pkg/epp/datastore/datastore.go | 2 +- pkg/epp/util/testing/wrappers.go | 2 +- test/utils/wrappers.go | 4 +- 12 files changed, 166 insertions(+), 91 deletions(-) create mode 100644 api/v1alpha2/shared_types.go diff --git a/api/v1alpha2/inferencemodel_types.go b/api/v1alpha2/inferencemodel_types.go index a75fd699..c011031e 100644 --- a/api/v1alpha2/inferencemodel_types.go +++ b/api/v1alpha2/inferencemodel_types.go @@ -107,25 +107,18 @@ type PoolObjectReference struct { // // +optional // +kubebuilder:default="inference.networking.x-k8s.io" - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` - Group string `json:"group,omitempty"` + Group Group `json:"group,omitempty"` // Kind is kind of the referent. For example "InferencePool". // // +optional // +kubebuilder:default="InferencePool" - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=63 - // +kubebuilder:validation:Pattern=`^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` - Kind string `json:"kind,omitempty"` + Kind Kind `json:"kind,omitempty"` // Name is the name of the referent. // - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 // +kubebuilder:validation:Required - Name string `json:"name"` + Name ObjectName `json:"name"` } // Criticality defines how important it is to serve the model compared to other models. diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index 2300f52a..ca76f347 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -90,11 +90,11 @@ type Extension struct { // ExtensionReference is a reference to the extension deployment. type ExtensionReference struct { // Group is the group of the referent. - // When unspecified or empty string, core API group is inferred. + // The default value is "", representing the Core API group. // // +optional // +kubebuilder:default="" - Group *string `json:"group,omitempty"` + Group *Group `json:"group,omitempty"` // Kind is the Kubernetes resource kind of the referent. For example // "Service". @@ -109,20 +109,19 @@ type ExtensionReference struct { // // +optional // +kubebuilder:default=Service - Kind *string `json:"kind,omitempty"` + Kind *Kind `json:"kind,omitempty"` // Name is the name of the referent. // // +kubebuilder:validation:Required - Name string `json:"name"` + Name ObjectName `json:"name"` - // The port number on the service running the extension. When unspecified, implementations SHOULD infer a - // default value of 9002 when the Kind is Service. + // The port number on the service running the extension. When unspecified, + // implementations SHOULD infer a default value of 9002 when the Kind is + // Service. // - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:validation:Maximum=65535 // +optional - PortNumber *int32 `json:"targetPortNumber,omitempty"` + PortNumber *PortNumber `json:"portNumber,omitempty"` } // ExtensionConnection encapsulates options that configures the connection to the extension. @@ -147,47 +146,6 @@ const ( FailClose ExtensionFailureMode = "FailClose" ) -// LabelKey was originally copied from: https://github.com/kubernetes-sigs/gateway-api/blob/99a3934c6bc1ce0874f3a4c5f20cafd8977ffcb4/apis/v1/shared_types.go#L694-L731 -// Duplicated as to not take an unexpected dependency on gw's API. -// -// LabelKey is the key of a label. This is used for validation -// of maps. This matches the Kubernetes "qualified name" validation that is used for labels. -// Labels are case sensitive, so: my-label and My-Label are considered distinct. -// -// Valid values include: -// -// * example -// * example.com -// * example.com/path -// * example.com/path.html -// -// Invalid values include: -// -// * example~ - "~" is an invalid character -// * example.com. - can not start or end with "." -// -// +kubebuilder:validation:MinLength=1 -// +kubebuilder:validation:MaxLength=253 -// +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$` -type LabelKey string - -// LabelValue is the value of a label. This is used for validation -// of maps. This matches the Kubernetes label validation rules: -// * must be 63 characters or less (can be empty), -// * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), -// * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. -// -// Valid values include: -// -// * MyValue -// * my.name -// * 123-my-value -// -// +kubebuilder:validation:MinLength=0 -// +kubebuilder:validation:MaxLength=63 -// +kubebuilder:validation:Pattern=`^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$` -type LabelValue string - // InferencePoolStatus defines the observed state of InferencePool type InferencePoolStatus struct { // Parents is a list of parent resources (usually Gateways) that are diff --git a/api/v1alpha2/shared_types.go b/api/v1alpha2/shared_types.go new file mode 100644 index 00000000..ea5ef299 --- /dev/null +++ b/api/v1alpha2/shared_types.go @@ -0,0 +1,108 @@ +/* +Copyright 2025 The Kubernetes 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 v1alpha2 + +// Group refers to a Kubernetes Group. It must either be an empty string or a +// RFC 1123 subdomain. +// +// This validation is based off of the corresponding Kubernetes validation: +// https://github.com/kubernetes/apimachinery/blob/02cfb53916346d085a6c6c7c66f882e3c6b0eca6/pkg/util/validation/validation.go#L208 +// +// Valid values include: +// +// * "" - empty string implies core Kubernetes API group +// * "gateway.networking.k8s.io" +// * "foo.example.com" +// +// Invalid values include: +// +// * "example.com/bar" - "/" is an invalid character +// +// +kubebuilder:validation:MaxLength=253 +// +kubebuilder:validation:Pattern=`^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` +type Group string + +// Kind refers to a Kubernetes Kind. +// +// Valid values include: +// +// * "Service" +// * "HTTPRoute" +// +// Invalid values include: +// +// * "invalid/kind" - "/" is an invalid character +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:Pattern=`^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` +type Kind string + +// ObjectName refers to the name of a Kubernetes object. +// Object names can have a variety of forms, including RFC 1123 subdomains, +// RFC 1123 labels, or RFC 1035 labels. +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=253 +type ObjectName string + +// PortNumber defines a network port. +// +// +kubebuilder:validation:Minimum=1 +// +kubebuilder:validation:Maximum=65535 +type PortNumber int32 + +// LabelKey was originally copied from: https://github.com/kubernetes-sigs/gateway-api/blob/99a3934c6bc1ce0874f3a4c5f20cafd8977ffcb4/apis/v1/shared_types.go#L694-L731 +// Duplicated as to not take an unexpected dependency on gw's API. +// +// LabelKey is the key of a label. This is used for validation +// of maps. This matches the Kubernetes "qualified name" validation that is used for labels. +// Labels are case sensitive, so: my-label and My-Label are considered distinct. +// +// Valid values include: +// +// * example +// * example.com +// * example.com/path +// * example.com/path.html +// +// Invalid values include: +// +// * example~ - "~" is an invalid character +// * example.com. - can not start or end with "." +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=253 +// +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?([A-Za-z0-9][-A-Za-z0-9_.]{0,61})?[A-Za-z0-9]$` +type LabelKey string + +// LabelValue is the value of a label. This is used for validation +// of maps. This matches the Kubernetes label validation rules: +// * must be 63 characters or less (can be empty), +// * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), +// * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. +// +// Valid values include: +// +// * MyValue +// * my.name +// * 123-my-value +// +// +kubebuilder:validation:MinLength=0 +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:Pattern=`^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$` +type LabelValue string diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 9b685969..4dad0eff 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -87,17 +87,17 @@ func (in *ExtensionReference) DeepCopyInto(out *ExtensionReference) { *out = *in if in.Group != nil { in, out := &in.Group, &out.Group - *out = new(string) + *out = new(Group) **out = **in } if in.Kind != nil { in, out := &in.Kind, &out.Kind - *out = new(string) + *out = new(Kind) **out = **in } if in.PortNumber != nil { in, out := &in.PortNumber, &out.PortNumber - *out = new(int32) + *out = new(PortNumber) **out = **in } } diff --git a/client-go/applyconfiguration/api/v1alpha2/extension.go b/client-go/applyconfiguration/api/v1alpha2/extension.go index b3802613..5e17e030 100644 --- a/client-go/applyconfiguration/api/v1alpha2/extension.go +++ b/client-go/applyconfiguration/api/v1alpha2/extension.go @@ -37,7 +37,7 @@ func Extension() *ExtensionApplyConfiguration { // WithGroup sets the Group field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Group field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithGroup(value string) *ExtensionApplyConfiguration { +func (b *ExtensionApplyConfiguration) WithGroup(value apiv1alpha2.Group) *ExtensionApplyConfiguration { b.ExtensionReferenceApplyConfiguration.Group = &value return b } @@ -45,7 +45,7 @@ func (b *ExtensionApplyConfiguration) WithGroup(value string) *ExtensionApplyCon // WithKind sets the Kind field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Kind field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithKind(value string) *ExtensionApplyConfiguration { +func (b *ExtensionApplyConfiguration) WithKind(value apiv1alpha2.Kind) *ExtensionApplyConfiguration { b.ExtensionReferenceApplyConfiguration.Kind = &value return b } @@ -53,7 +53,7 @@ func (b *ExtensionApplyConfiguration) WithKind(value string) *ExtensionApplyConf // WithName sets the Name field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Name field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithName(value string) *ExtensionApplyConfiguration { +func (b *ExtensionApplyConfiguration) WithName(value apiv1alpha2.ObjectName) *ExtensionApplyConfiguration { b.ExtensionReferenceApplyConfiguration.Name = &value return b } @@ -61,7 +61,7 @@ func (b *ExtensionApplyConfiguration) WithName(value string) *ExtensionApplyConf // WithPortNumber sets the PortNumber field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the PortNumber field is set to the value of the last call. -func (b *ExtensionApplyConfiguration) WithPortNumber(value int32) *ExtensionApplyConfiguration { +func (b *ExtensionApplyConfiguration) WithPortNumber(value apiv1alpha2.PortNumber) *ExtensionApplyConfiguration { b.ExtensionReferenceApplyConfiguration.PortNumber = &value return b } diff --git a/client-go/applyconfiguration/api/v1alpha2/extensionreference.go b/client-go/applyconfiguration/api/v1alpha2/extensionreference.go index 71034710..937e5795 100644 --- a/client-go/applyconfiguration/api/v1alpha2/extensionreference.go +++ b/client-go/applyconfiguration/api/v1alpha2/extensionreference.go @@ -17,13 +17,17 @@ limitations under the License. package v1alpha2 +import ( + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + // ExtensionReferenceApplyConfiguration represents a declarative configuration of the ExtensionReference type for use // with apply. type ExtensionReferenceApplyConfiguration struct { - Group *string `json:"group,omitempty"` - Kind *string `json:"kind,omitempty"` - Name *string `json:"name,omitempty"` - PortNumber *int32 `json:"targetPortNumber,omitempty"` + Group *apiv1alpha2.Group `json:"group,omitempty"` + Kind *apiv1alpha2.Kind `json:"kind,omitempty"` + Name *apiv1alpha2.ObjectName `json:"name,omitempty"` + PortNumber *apiv1alpha2.PortNumber `json:"portNumber,omitempty"` } // ExtensionReferenceApplyConfiguration constructs a declarative configuration of the ExtensionReference type for use with @@ -35,7 +39,7 @@ func ExtensionReference() *ExtensionReferenceApplyConfiguration { // WithGroup sets the Group field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Group field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithGroup(value string) *ExtensionReferenceApplyConfiguration { +func (b *ExtensionReferenceApplyConfiguration) WithGroup(value apiv1alpha2.Group) *ExtensionReferenceApplyConfiguration { b.Group = &value return b } @@ -43,7 +47,7 @@ func (b *ExtensionReferenceApplyConfiguration) WithGroup(value string) *Extensio // WithKind sets the Kind field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Kind field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithKind(value string) *ExtensionReferenceApplyConfiguration { +func (b *ExtensionReferenceApplyConfiguration) WithKind(value apiv1alpha2.Kind) *ExtensionReferenceApplyConfiguration { b.Kind = &value return b } @@ -51,7 +55,7 @@ func (b *ExtensionReferenceApplyConfiguration) WithKind(value string) *Extension // WithName sets the Name field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Name field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithName(value string) *ExtensionReferenceApplyConfiguration { +func (b *ExtensionReferenceApplyConfiguration) WithName(value apiv1alpha2.ObjectName) *ExtensionReferenceApplyConfiguration { b.Name = &value return b } @@ -59,7 +63,7 @@ func (b *ExtensionReferenceApplyConfiguration) WithName(value string) *Extension // WithPortNumber sets the PortNumber field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the PortNumber field is set to the value of the last call. -func (b *ExtensionReferenceApplyConfiguration) WithPortNumber(value int32) *ExtensionReferenceApplyConfiguration { +func (b *ExtensionReferenceApplyConfiguration) WithPortNumber(value apiv1alpha2.PortNumber) *ExtensionReferenceApplyConfiguration { b.PortNumber = &value return b } diff --git a/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go b/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go index cc88c950..20abf6b2 100644 --- a/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go +++ b/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go @@ -17,12 +17,16 @@ limitations under the License. package v1alpha2 +import ( + apiv1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + // PoolObjectReferenceApplyConfiguration represents a declarative configuration of the PoolObjectReference type for use // with apply. type PoolObjectReferenceApplyConfiguration struct { - Group *string `json:"group,omitempty"` - Kind *string `json:"kind,omitempty"` - Name *string `json:"name,omitempty"` + Group *apiv1alpha2.Group `json:"group,omitempty"` + Kind *apiv1alpha2.Kind `json:"kind,omitempty"` + Name *apiv1alpha2.ObjectName `json:"name,omitempty"` } // PoolObjectReferenceApplyConfiguration constructs a declarative configuration of the PoolObjectReference type for use with @@ -34,7 +38,7 @@ func PoolObjectReference() *PoolObjectReferenceApplyConfiguration { // WithGroup sets the Group field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Group field is set to the value of the last call. -func (b *PoolObjectReferenceApplyConfiguration) WithGroup(value string) *PoolObjectReferenceApplyConfiguration { +func (b *PoolObjectReferenceApplyConfiguration) WithGroup(value apiv1alpha2.Group) *PoolObjectReferenceApplyConfiguration { b.Group = &value return b } @@ -42,7 +46,7 @@ func (b *PoolObjectReferenceApplyConfiguration) WithGroup(value string) *PoolObj // WithKind sets the Kind field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Kind field is set to the value of the last call. -func (b *PoolObjectReferenceApplyConfiguration) WithKind(value string) *PoolObjectReferenceApplyConfiguration { +func (b *PoolObjectReferenceApplyConfiguration) WithKind(value apiv1alpha2.Kind) *PoolObjectReferenceApplyConfiguration { b.Kind = &value return b } @@ -50,7 +54,7 @@ func (b *PoolObjectReferenceApplyConfiguration) WithKind(value string) *PoolObje // WithName sets the Name field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Name field is set to the value of the last call. -func (b *PoolObjectReferenceApplyConfiguration) WithName(value string) *PoolObjectReferenceApplyConfiguration { +func (b *PoolObjectReferenceApplyConfiguration) WithName(value apiv1alpha2.ObjectName) *PoolObjectReferenceApplyConfiguration { b.Name = &value return b } diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml index 8a7ad938..15b79b69 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml @@ -56,7 +56,9 @@ spec: default: "" description: |- Group is the group of the referent. - When unspecified or empty string, core API group is inferred. + The default value is "", representing the Core API group. + maxLength: 253 + pattern: ^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string kind: default: Service @@ -71,14 +73,20 @@ spec: terms of conformance. They also may not be safe to forward to (see CVE-2021-25740 for more information). Implementations MUST NOT support ExternalName Services. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ type: string name: description: Name is the name of the referent. + maxLength: 253 + minLength: 1 type: string - targetPortNumber: + portNumber: description: |- - The port number on the service running the extension. When unspecified, implementations SHOULD infer a - default value of 9002 when the Kind is Service. + The port number on the service running the extension. When unspecified, + implementations SHOULD infer a default value of 9002 when the Kind is + Service. format: int32 maximum: 65535 minimum: 1 diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index 2b50537a..8318324f 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -58,7 +58,7 @@ func (c *InferenceModelReconciler) Reconcile(ctx context.Context, req ctrl.Reque notFound = true } - if notFound || !infModel.DeletionTimestamp.IsZero() || infModel.Spec.PoolRef.Name != c.PoolNamespacedName.Name { + if notFound || !infModel.DeletionTimestamp.IsZero() || infModel.Spec.PoolRef.Name != v1alpha2.ObjectName(c.PoolNamespacedName.Name) { // InferenceModel object got deleted or changed the referenced pool. err := c.handleModelDeleted(ctx, req.NamespacedName) return ctrl.Result{}, err @@ -128,5 +128,5 @@ func (c *InferenceModelReconciler) SetupWithManager(ctx context.Context, mgr ctr } func (c *InferenceModelReconciler) eventPredicate(infModel *v1alpha2.InferenceModel) bool { - return (infModel.Spec.PoolRef.Name == c.PoolNamespacedName.Name) && (infModel.GetNamespace() == c.PoolNamespacedName.Namespace) + return (infModel.Spec.PoolRef.Name == v1alpha2.ObjectName(c.PoolNamespacedName.Name)) && (infModel.GetNamespace() == c.PoolNamespacedName.Namespace) } diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index f8d4722a..c7050437 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -183,7 +183,7 @@ func (ds *datastore) ModelResync(ctx context.Context, c client.Client, modelName for i := range models.Items { m := &models.Items[i] if m.Spec.ModelName != modelName || // The index should filter those out, but just in case! - m.Spec.PoolRef.Name != ds.pool.Name || // We don't care about other pools, we could setup an index on this too! + m.Spec.PoolRef.Name != v1alpha2.ObjectName(ds.pool.Name) || // We don't care about other pools, we could setup an index on this too! !m.DeletionTimestamp.IsZero() { // ignore objects marked for deletion continue } diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go index bfcf2690..2b8a4fd1 100644 --- a/pkg/epp/util/testing/wrappers.go +++ b/pkg/epp/util/testing/wrappers.go @@ -110,7 +110,7 @@ func (m *InferenceModelWrapper) ModelName(modelName string) *InferenceModelWrapp } func (m *InferenceModelWrapper) PoolName(poolName string) *InferenceModelWrapper { - m.Spec.PoolRef = v1alpha2.PoolObjectReference{Name: poolName} + m.Spec.PoolRef = v1alpha2.PoolObjectReference{Name: v1alpha2.ObjectName(poolName)} return m } diff --git a/test/utils/wrappers.go b/test/utils/wrappers.go index 3280cb11..867118c1 100644 --- a/test/utils/wrappers.go +++ b/test/utils/wrappers.go @@ -58,9 +58,9 @@ func (m *InferenceModelWrapper) SetCriticality(level v1alpha2.Criticality) *Infe // for group/kind and name as the PoolObjectReference name. func (m *InferenceModelWrapper) SetPoolRef(name string) *InferenceModelWrapper { ref := v1alpha2.PoolObjectReference{ - Group: v1alpha2.GroupVersion.Group, + Group: v1alpha2.Group(v1alpha2.GroupVersion.Group), Kind: "inferencepools", - Name: name, + Name: v1alpha2.ObjectName(name), } m.Spec.PoolRef = ref return m From 4c5aa2a7f8872fa3cdd91547d8f598937f0eac81 Mon Sep 17 00:00:00 2001 From: Kuromesi Date: Sat, 1 Mar 2025 10:40:56 +0800 Subject: [PATCH 066/260] create pods during integration tests (#431) * create pods during integration tests Signed-off-by: Kuromesi * fix Signed-off-by: Kuromesi --------- Signed-off-by: Kuromesi --- pkg/epp/test/utils.go | 4 +- pkg/epp/util/testing/wrappers.go | 14 ++++++ test/integration/hermetic_test.go | 73 ++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/pkg/epp/test/utils.go b/pkg/epp/test/utils.go index a916bda2..b18b0919 100644 --- a/pkg/epp/test/utils.go +++ b/pkg/epp/test/utils.go @@ -114,10 +114,10 @@ func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.Proces } func FakePodMetrics(index int, metrics datastore.Metrics) *datastore.PodMetrics { - address := fmt.Sprintf("address-%v", index) + address := fmt.Sprintf("192.168.1.%d", index+1) pod := datastore.PodMetrics{ Pod: datastore.Pod{ - NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index)}, + NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, Address: address, }, Metrics: metrics, diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go index 2b8a4fd1..2693734f 100644 --- a/pkg/epp/util/testing/wrappers.go +++ b/pkg/epp/util/testing/wrappers.go @@ -40,6 +40,20 @@ func MakePod(podName string) *PodWrapper { } } +// Complete sets necessary fields for a Pod to make it not denied by the apiserver +func (p *PodWrapper) Complete() *PodWrapper { + if p.Pod.Namespace == "" { + p.Namespace("default") + } + p.Spec.Containers = []corev1.Container{ + { + Name: "mock-vllm", + Image: "mock-vllm:latest", + }, + } + return p +} + func (p *PodWrapper) Namespace(ns string) *PodWrapper { p.ObjectMeta.Namespace = ns return p diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index de32dce0..7755795b 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -112,7 +112,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { { Header: &configPb.HeaderValue{ Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("address-1:8000"), + RawValue: []byte("192.168.1.2:8000"), }, }, { @@ -122,7 +122,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: makeMetadata("address-1:8000"), + wantMetadata: makeMetadata("192.168.1.2:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), wantMetrics: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. @@ -165,7 +165,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { { Header: &configPb.HeaderValue{ Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("address-1:8000"), + RawValue: []byte("192.168.1.2:8000"), }, }, { @@ -175,7 +175,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: makeMetadata("address-1:8000"), + wantMetadata: makeMetadata("192.168.1.2:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), wantMetrics: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. @@ -219,7 +219,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { { Header: &configPb.HeaderValue{ Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("address-2:8000"), + RawValue: []byte("192.168.1.3:8000"), }, }, { @@ -229,7 +229,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: makeMetadata("address-2:8000"), + wantMetadata: makeMetadata("192.168.1.3:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), wantMetrics: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. @@ -316,7 +316,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { { Header: &configPb.HeaderValue{ Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("address-0:8000"), + RawValue: []byte("192.168.1.1:8000"), }, }, { @@ -326,7 +326,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetadata: makeMetadata("address-0:8000"), + wantMetadata: makeMetadata("192.168.1.1:8000"), wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), wantMetrics: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. @@ -343,7 +343,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(test.pods) + client, cleanup := setUpHermeticServer(t, test.pods) t.Cleanup(cleanup) want := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestBody{ @@ -389,7 +389,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(podMetrics []*datastore.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { pms := make(map[types.NamespacedName]*datastore.PodMetrics) for _, pm := range podMetrics { pms[pm.NamespacedName] = pm @@ -397,23 +397,44 @@ func setUpHermeticServer(podMetrics []*datastore.PodMetrics) (client extProcPb.E pmc := &backend.FakePodMetricsClient{Res: pms} serverCtx, stopServer := context.WithCancel(context.Background()) - go func() { - serverRunner.Datastore.PodDeleteAll() - for _, pm := range podMetrics { - pod := utiltesting.MakePod(pm.NamespacedName.Name). - Namespace(pm.NamespacedName.Namespace). - ReadyCondition(). - IP(pm.Address). - ObjRef() - serverRunner.Datastore.PodUpdateOrAddIfNotExist(pod) - serverRunner.Datastore.PodUpdateMetricsIfExist(pm.NamespacedName, &pm.Metrics) + + // TODO: this should be consistent with the inference pool + podLabels := map[string]string{ + "app": "vllm-llama2-7b-pool", + } + + for _, pm := range podMetrics { + pod := utiltesting.MakePod(pm.NamespacedName.Name). + Namespace(pm.NamespacedName.Namespace). + ReadyCondition(). + Labels(podLabels). + IP(pm.Address). + Complete(). + ObjRef() + + copy := pod.DeepCopy() + if err := k8sClient.Create(context.Background(), copy); err != nil { + logutil.Fatal(logger, err, "Failed to create pod", "pod", pm.NamespacedName) } - serverRunner.Provider = backend.NewProvider(pmc, serverRunner.Datastore) + + // since no pod controllers deployed in fake environment, we manually update pod status + copy.Status = pod.Status + if err := k8sClient.Status().Update(context.Background(), copy); err != nil { + logutil.Fatal(logger, err, "Failed to update pod status", "pod", pm.NamespacedName) + } + } + serverRunner.Provider = backend.NewProvider(pmc, serverRunner.Datastore) + go func() { if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { logutil.Fatal(logger, err, "Failed to start ext-proc server") } }() + // check if all pods are synced to datastore + assert.EventuallyWithT(t, func(t *assert.CollectT) { + assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podMetrics), "Datastore not synced") + }, 10*time.Second, time.Second) + address := fmt.Sprintf("localhost:%v", port) // Create a grpc connection conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -430,6 +451,16 @@ func setUpHermeticServer(podMetrics []*datastore.PodMetrics) (client extProcPb.E cancel() conn.Close() stopServer() + + // clear created pods + for _, pm := range podMetrics { + pod := utiltesting.MakePod(pm.NamespacedName.Name). + Namespace(pm.NamespacedName.Namespace).Complete().ObjRef() + + if err := k8sClient.Delete(context.Background(), pod); err != nil { + logutil.Fatal(logger, err, "Failed to delete pod", "pod", pm.NamespacedName) + } + } // wait a little until the goroutines actually exit time.Sleep(5 * time.Second) } From 406ffee096926c3106228126307ab2335abd2a95 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Sun, 2 Mar 2025 21:46:56 +0200 Subject: [PATCH 067/260] fixed typos (#433) Signed-off-by: Nir Rozenbaum --- docs/proposals/003-model-server-protocol/README.md | 2 +- docs/proposals/004-endpoint-picker-protocol/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/proposals/003-model-server-protocol/README.md b/docs/proposals/003-model-server-protocol/README.md index 44ecf4e1..2ab557f7 100644 --- a/docs/proposals/003-model-server-protocol/README.md +++ b/docs/proposals/003-model-server-protocol/README.md @@ -43,7 +43,7 @@ The model server MUST expose the following LoRA adapter metrics via the same Pro * Metric value: The last updated timestamp (so the EPP can find the latest). * Metric labels: * `max_lora`: The maximum number of adapters that can be loaded to GPU memory to serve a batch. - Requests will be queued if the model server has reached MaxActiveAdapter and canno load the + Requests will be queued if the model server has reached MaxActiveAdapter and cannot load the requested adapter. Example: `"max_lora": "8"`. * `running_lora_adapters`: A comma separated list of adapters that are currently loaded in GPU memory and ready to serve requests. Example: `"running_lora_adapters": "adapter1, adapter2"` diff --git a/docs/proposals/004-endpoint-picker-protocol/README.md b/docs/proposals/004-endpoint-picker-protocol/README.md index 1e27ff0f..3657a10e 100644 --- a/docs/proposals/004-endpoint-picker-protocol/README.md +++ b/docs/proposals/004-endpoint-picker-protocol/README.md @@ -7,7 +7,7 @@ found [here](../../../pkg/epp/). This doc defines the protocol between the EPP and the proxy (e.g, Envoy). The EPP MUST implement the Envoy -[external processing service](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor)protocol. +[external processing service](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor) protocol. For each HTTP request, the EPP MUST communicate to the proxy the picked model server endpoint via: From ddd066ae09550acb13ec0d81ca0dfc1ef8d6b0ef Mon Sep 17 00:00:00 2001 From: Rob Scott Date: Mon, 3 Mar 2025 19:57:42 -0800 Subject: [PATCH 068/260] Adding Accepted and ResolvedRefs conditions to InferencePool (#446) --- api/v1alpha2/inferencepool_types.go | 61 ++++++++++++++++--- ...ce.networking.x-k8s.io_inferencepools.yaml | 2 +- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index ca76f347..19ec799f 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -159,10 +159,11 @@ type InferencePoolStatus struct { Parents []PoolStatus `json:"parent,omitempty"` } -// PoolStatus defines the observed state of InferencePool from a gateway. +// PoolStatus defines the observed state of InferencePool from a Gateway. type PoolStatus struct { // GatewayRef indicates the gateway that observed state of InferencePool. GatewayRef corev1.ObjectReference `json:"parentRef"` + // Conditions track the state of the InferencePool. // // Known condition types are: @@ -180,27 +181,67 @@ type PoolStatus struct { // InferencePoolConditionType is a type of condition for the InferencePool type InferencePoolConditionType string -// InferencePoolConditionReason is the reason for a given InferencePoolConditionType -type InferencePoolConditionReason string +// InferencePoolReason is the reason for a given InferencePoolConditionType +type InferencePoolReason string const ( - // PoolConditionReady indicates if the pool is ready to accept traffic, and if not, why. + // This condition indicates whether the route has been accepted or rejected + // by a Gateway, and why. // // Possible reasons for this condition to be True are: // - // * "Ready" + // * "Accepted" + // + // Possible reasons for this condition to be False are: + // + // * "NotSupportedByGateway" // // Possible reasons for this condition to be Unknown are: // // * "Pending" // - PoolConditionReady InferencePoolConditionType = "Ready" + // Controllers MAY raise this condition with other reasons, but should + // prefer to use the reasons listed above to improve interoperability. + InferencePoolConditionAccepted InferencePoolConditionType = "Accepted" + + // This reason is used with the "Accepted" condition when the Route has been + // accepted by the Gateway. + InferencePoolReasonAccepted InferencePoolReason = "Accepted" + + // This reason is used with the "Accepted" condition when the InferencePool + // has not been accepted by a Gateway because the Gateway does not support + // InferencePool as a backend. + InferencePoolReasonNotSupportedByGateway InferencePoolReason = "NotSupportedByGateway" + + // This reason is used with the "Accepted" when a controller has not yet + // reconciled the route. + InferencePoolReasonPending InferencePoolReason = "Pending" +) + +const ( + // This condition indicates whether the controller was able to resolve all + // the object references for the InferencePool. + // + // Possible reasons for this condition to be true are: + // + // * "ResolvedRefs" + // + // Possible reasons for this condition to be False are: + // + // * "InvalidExtnesionRef" + // + // Controllers MAY raise this condition with other reasons, but should + // prefer to use the reasons listed above to improve interoperability. + ModelConditionResolvedRefs InferencePoolConditionType = "ResolvedRefs" - // PoolReasonReady is the desired state. The pool and its components are initialized and ready for traffic. - PoolReasonReady InferencePoolConditionReason = "Ready" + // This reason is used with the "ResolvedRefs" condition when the condition + // is true. + ModelReasonResolvedRefs InferencePoolReason = "ResolvedRefs" - // PoolReasonPending is the initial state, and indicates that the controller has not yet reconciled this pool. - PoolReasonPending InferencePoolConditionReason = "Pending" + // This reason is used with the "ResolvedRefs" condition when the + // ExtensionRef is invalid in some way. This can include an unsupported kind + // or API group, or a reference to a resource that can not be found. + ModelReasonInvalidExtensionRef InferencePoolReason = "InvalidExtensionRef" ) func init() { diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml index 15b79b69..5767508b 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml @@ -146,7 +146,7 @@ spec: means the route has not been attached to any Gateway. items: description: PoolStatus defines the observed state of InferencePool - from a gateway. + from a Gateway. properties: conditions: default: From 83442b0f548ba60b5c1e5c4c0d6fb6c74319fc7e Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Tue, 4 Mar 2025 10:47:43 -0500 Subject: [PATCH 069/260] Add code for Envoy extension that support body-to-header translation (#355) --- body-based-routing.Dockerfile | 30 ++++ cmd/body-based-routing/health.go | 40 +++++ cmd/body-based-routing/main.go | 137 ++++++++++++++++++ pkg/body-based-routing/README.md | 14 ++ pkg/body-based-routing/handlers/request.go | 97 +++++++++++++ .../handlers/request_test.go | 128 ++++++++++++++++ pkg/body-based-routing/handlers/response.go | 48 ++++++ pkg/body-based-routing/handlers/server.go | 90 ++++++++++++ pkg/body-based-routing/server/runserver.go | 120 +++++++++++++++ 9 files changed, 704 insertions(+) create mode 100644 body-based-routing.Dockerfile create mode 100644 cmd/body-based-routing/health.go create mode 100644 cmd/body-based-routing/main.go create mode 100644 pkg/body-based-routing/README.md create mode 100644 pkg/body-based-routing/handlers/request.go create mode 100644 pkg/body-based-routing/handlers/request_test.go create mode 100644 pkg/body-based-routing/handlers/response.go create mode 100644 pkg/body-based-routing/handlers/server.go create mode 100644 pkg/body-based-routing/server/runserver.go diff --git a/body-based-routing.Dockerfile b/body-based-routing.Dockerfile new file mode 100644 index 00000000..e0afcf20 --- /dev/null +++ b/body-based-routing.Dockerfile @@ -0,0 +1,30 @@ +# Dockerfile has specific requirement to put this ARG at the beginning: +# https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact +ARG BUILDER_IMAGE=golang:1.23 +ARG BASE_IMAGE=gcr.io/distroless/static:nonroot + +## Multistage build +FROM ${BUILDER_IMAGE} AS builder +ENV CGO_ENABLED=0 +ENV GOOS=linux +ENV GOARCH=amd64 + +# Dependencies +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +# Sources +COPY cmd ./cmd +COPY pkg ./pkg +COPY internal ./internal +WORKDIR /src/cmd/body-based-routing +RUN go build -o /body-based-routing + +## Multistage deploy +FROM ${BASE_IMAGE} + +WORKDIR / +COPY --from=builder /body-based-routing /body-based-routing + +ENTRYPOINT ["/body-based-routing"] diff --git a/cmd/body-based-routing/health.go b/cmd/body-based-routing/health.go new file mode 100644 index 00000000..7d1b5fd5 --- /dev/null +++ b/cmd/body-based-routing/health.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 The Kubernetes 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 main + +import ( + "context" + + "github.com/go-logr/logr" + "google.golang.org/grpc/codes" + healthPb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +type healthServer struct { + logger logr.Logger +} + +func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) { + s.logger.V(logutil.VERBOSE).Info("gRPC health check serving", "service", in.Service) + return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil +} + +func (s *healthServer) Watch(in *healthPb.HealthCheckRequest, srv healthPb.Health_WatchServer) error { + return status.Error(codes.Unimplemented, "Watch is not implemented") +} diff --git a/cmd/body-based-routing/main.go b/cmd/body-based-routing/main.go new file mode 100644 index 00000000..3f586788 --- /dev/null +++ b/cmd/body-based-routing/main.go @@ -0,0 +1,137 @@ +/* +Copyright 2025 The Kubernetes 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 main + +import ( + "flag" + "os" + + "github.com/go-logr/logr" + uberzap "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "google.golang.org/grpc" + healthPb "google.golang.org/grpc/health/grpc_health_v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/server" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +var ( + grpcPort = flag.Int( + "grpcPort", + runserver.DefaultGrpcPort, + "The gRPC port used for communicating with Envoy proxy") + grpcHealthPort = flag.Int( + "grpcHealthPort", + 9003, + "The port used for gRPC liveness and readiness probes") + logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") + + setupLog = ctrl.Log.WithName("setup") +) + +func main() { + if err := run(); err != nil { + os.Exit(1) + } +} + +func run() error { + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + initLogging(&opts) + + // Print all flag values + flags := make(map[string]any) + flag.VisitAll(func(f *flag.Flag) { + flags[f.Name] = f.Value + }) + setupLog.Info("Flags processed", "flags", flags) + + // Init runtime. + cfg, err := ctrl.GetConfig() + if err != nil { + setupLog.Error(err, "Failed to get rest config") + return err + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{}) + if err != nil { + setupLog.Error(err, "Failed to create manager", "config", cfg) + return err + } + + ctx := ctrl.SetupSignalHandler() + + // Setup runner. + serverRunner := &runserver.ExtProcServerRunner{GrpcPort: *grpcPort} + + // Register health server. + if err := registerHealthServer(mgr, ctrl.Log.WithName("health"), *grpcHealthPort); err != nil { + return err + } + + // Register ext-proc server. + if err := mgr.Add(serverRunner.AsRunnable(ctrl.Log.WithName("ext-proc"))); err != nil { + setupLog.Error(err, "Failed to register ext-proc gRPC server") + return err + } + + // Start the manager. This blocks until a signal is received. + setupLog.Info("Manager starting") + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "Error starting manager") + return err + } + setupLog.Info("Manager terminated") + return nil +} + +// registerHealthServer adds the Health gRPC server as a Runnable to the given manager. +func registerHealthServer(mgr manager.Manager, logger logr.Logger, port int) error { + srv := grpc.NewServer() + healthPb.RegisterHealthServer(srv, &healthServer{ + logger: logger, + }) + if err := mgr.Add( + runnable.NoLeaderElection(runnable.GRPCServer("health", srv, port))); err != nil { + setupLog.Error(err, "Failed to register health server") + return err + } + return nil +} + +func initLogging(opts *zap.Options) { + useV := true + flag.Visit(func(f *flag.Flag) { + if f.Name == "zap-log-level" { + useV = false + } + }) + if useV { + // See https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/log/zap#Options.Level + lvl := -1 * (*logVerbosity) + opts.Level = uberzap.NewAtomicLevelAt(zapcore.Level(int8(lvl))) + } + + logger := zap.New(zap.UseFlagOptions(opts), zap.RawZapOpts(uberzap.AddCaller())) + ctrl.SetLogger(logger) +} diff --git a/pkg/body-based-routing/README.md b/pkg/body-based-routing/README.md new file mode 100644 index 00000000..b5b6f770 --- /dev/null +++ b/pkg/body-based-routing/README.md @@ -0,0 +1,14 @@ +# Body-Based Routing +This package provides an extension that can be deployed to write the `model` +HTTP body parameter as a header (X-Gateway-Model-Name) so as to enable routing capabilities on the +model name. + +As per OpenAI spec, it is standard for the model name to be included in the +body of the HTTP request. However, most implementations do not support routing +based on the request body. This extension helps bridge that gap for clients. +This extension works by parsing the request body. If it finds a `model` parameter in the +request body, it will copy the value of that parameter into a request header. + +This extension is intended to be paired with an `ext_proc` capable Gateway. There is not +a standard way to represent this kind of extension in Gateway API yet, so we recommend +referring to implementation-specific documentation for how to deploy this extension. diff --git a/pkg/body-based-routing/handlers/request.go b/pkg/body-based-routing/handlers/request.go new file mode 100644 index 00000000..3c5037a9 --- /dev/null +++ b/pkg/body-based-routing/handlers/request.go @@ -0,0 +1,97 @@ +/* +Copyright 2025 The Kubernetes 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 handlers + +import ( + "context" + "encoding/json" + "fmt" + + basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + eppb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "sigs.k8s.io/controller-runtime/pkg/log" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// HandleRequestBody handles request bodies. +func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*eppb.ProcessingResponse, error) { + logger := log.FromContext(ctx) + + var data map[string]any + if err := json.Unmarshal(body.GetBody(), &data); err != nil { + return nil, err + } + + modelVal, ok := data["model"] + if !ok { + logger.V(logutil.DEFAULT).Info("Request body does not contain model parameter") + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestBody{ + RequestBody: &eppb.BodyResponse{}, + }, + }, nil + } + + modelStr, ok := modelVal.(string) + if !ok { + logger.V(logutil.DEFAULT).Info("Model parameter value is not a string") + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestBody{ + RequestBody: &eppb.BodyResponse{}, + }, + }, fmt.Errorf("the model parameter value %v is not a string", modelVal) + } + + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestBody{ + RequestBody: &eppb.BodyResponse{ + Response: &eppb.CommonResponse{ + // Necessary so that the new headers are used in the routing decision. + ClearRouteCache: true, + HeaderMutation: &eppb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte(modelStr), + }, + }, + }, + }, + }, + }, + }, + }, nil +} + +// HandleRequestHeaders handles request headers. +func (s *Server) HandleRequestHeaders(headers *eppb.HttpHeaders) (*eppb.ProcessingResponse, error) { + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &eppb.HeadersResponse{}, + }, + }, nil +} + +// HandleRequestTrailers handles request trailers. +func (s *Server) HandleRequestTrailers(trailers *eppb.HttpTrailers) (*eppb.ProcessingResponse, error) { + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestTrailers{ + RequestTrailers: &eppb.TrailersResponse{}, + }, + }, nil +} diff --git a/pkg/body-based-routing/handlers/request_test.go b/pkg/body-based-routing/handlers/request_test.go new file mode 100644 index 00000000..9bdac521 --- /dev/null +++ b/pkg/body-based-routing/handlers/request_test.go @@ -0,0 +1,128 @@ +/* +Copyright 2025 The Kubernetes 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 handlers + +import ( + "context" + "testing" + + basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/testing/protocmp" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +const ( + bodyWithModel = ` + { + "model": "foo", + "prompt": "Tell me a joke" + } + ` + bodyWithModelNoStr = ` + { + "model": 1, + "prompt": "Tell me a joke" + } + ` + bodyWithoutModel = ` + { + "prompt": "Tell me a joke" + } + ` +) + +func TestHandleRequestBody(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + + tests := []struct { + name string + body *extProcPb.HttpBody + want *extProcPb.ProcessingResponse + wantErr bool + }{ + { + name: "malformed body", + body: &extProcPb.HttpBody{ + Body: []byte("malformed json"), + }, + wantErr: true, + }, + { + name: "model not found", + body: &extProcPb.HttpBody{ + Body: []byte(bodyWithoutModel), + }, + want: &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{}, + }, + }, + }, + { + name: "model is not string", + body: &extProcPb.HttpBody{ + Body: []byte(bodyWithModelNoStr), + }, + wantErr: true, + }, + { + name: "success", + body: &extProcPb.HttpBody{ + Body: []byte(bodyWithModel), + }, + want: &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + // Necessary so that the new headers are used in the routing decision. + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte("foo"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := &Server{} + resp, err := server.HandleRequestBody(ctx, test.body) + if err != nil { + if !test.wantErr { + t.Fatalf("HandleRequestBody returned unexpected error: %v, want %v", err, test.wantErr) + } + return + } + + if diff := cmp.Diff(test.want, resp, protocmp.Transform()); diff != "" { + t.Errorf("HandleRequestBody returned unexpected response, diff(-want, +got): %v", diff) + } + }) + } +} diff --git a/pkg/body-based-routing/handlers/response.go b/pkg/body-based-routing/handlers/response.go new file mode 100644 index 00000000..a62aa076 --- /dev/null +++ b/pkg/body-based-routing/handlers/response.go @@ -0,0 +1,48 @@ +/* +Copyright 2025 The Kubernetes 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 handlers + +import ( + eppb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" +) + +// HandleResponseHeaders handles response headers. +func (s *Server) HandleResponseHeaders(headers *eppb.HttpHeaders) (*eppb.ProcessingResponse, error) { + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &eppb.HeadersResponse{}, + }, + }, nil +} + +// HandleResponseBody handles response bodies. +func (s *Server) HandleResponseBody(body *eppb.HttpBody) (*eppb.ProcessingResponse, error) { + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_ResponseBody{ + ResponseBody: &eppb.BodyResponse{}, + }, + }, nil +} + +// HandleResponseTrailers handles response trailers. +func (s *Server) HandleResponseTrailers(trailers *eppb.HttpTrailers) (*eppb.ProcessingResponse, error) { + return &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_ResponseTrailers{ + ResponseTrailers: &eppb.TrailersResponse{}, + }, + }, nil +} diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/body-based-routing/handlers/server.go new file mode 100644 index 00000000..434dd530 --- /dev/null +++ b/pkg/body-based-routing/handlers/server.go @@ -0,0 +1,90 @@ +/* +Copyright 2025 The Kubernetes 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 handlers + +import ( + "context" + "errors" + "io" + + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "sigs.k8s.io/controller-runtime/pkg/log" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func NewServer() *Server { + return &Server{} +} + +// Server implements the Envoy external processing server. +// https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor.proto +type Server struct{} + +func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { + ctx := srv.Context() + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Processing") + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + req, recvErr := srv.Recv() + if recvErr == io.EOF || errors.Is(recvErr, context.Canceled) { + return nil + } + if recvErr != nil { + // This error occurs very frequently, though it doesn't seem to have any impact. + // TODO Figure out if we can remove this noise. + loggerVerbose.Error(recvErr, "Cannot receive stream request") + return status.Errorf(codes.Unknown, "cannot receive stream request: %v", recvErr) + } + + var resp *extProcPb.ProcessingResponse + var err error + switch v := req.Request.(type) { + case *extProcPb.ProcessingRequest_RequestHeaders: + resp, err = s.HandleRequestHeaders(req.GetRequestHeaders()) + case *extProcPb.ProcessingRequest_RequestBody: + resp, err = s.HandleRequestBody(ctx, req.GetRequestBody()) + case *extProcPb.ProcessingRequest_ResponseHeaders: + resp, err = s.HandleResponseHeaders(req.GetResponseHeaders()) + case *extProcPb.ProcessingRequest_ResponseBody: + resp, err = s.HandleResponseBody(req.GetResponseBody()) + default: + logger.V(logutil.DEFAULT).Error(nil, "Unknown Request type", "request", v) + return status.Error(codes.Unknown, "unknown request type") + } + + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) + return status.Errorf(status.Code(err), "failed to handle request: %v", err) + } + + loggerVerbose.Info("Response generated", "response", resp) + if err := srv.Send(resp); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Send failed") + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + } +} diff --git a/pkg/body-based-routing/server/runserver.go b/pkg/body-based-routing/server/runserver.go new file mode 100644 index 00000000..b04602bb --- /dev/null +++ b/pkg/body-based-routing/server/runserver.go @@ -0,0 +1,120 @@ +/* +Copyright 2025 The Kubernetes 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 server + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "time" + + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" + "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/handlers" +) + +// ExtProcServerRunner provides methods to manage an external process server. +type ExtProcServerRunner struct { + GrpcPort int +} + +// Default values for CLI flags in main +const ( + DefaultGrpcPort = 9002 // default for --grpcPort +) + +func NewDefaultExtProcServerRunner() *ExtProcServerRunner { + return &ExtProcServerRunner{ + GrpcPort: DefaultGrpcPort, + } +} + +// AsRunnable returns a Runnable that can be used to start the ext-proc gRPC server. +// The runnable implements LeaderElectionRunnable with leader election disabled. +func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { + return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { + cert, err := createSelfSignedTLSCertificate(logger) + if err != nil { + logger.Error(err, "Failed to create self signed certificate") + return err + } + creds := credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}}) + + srv := grpc.NewServer(grpc.Creds(creds)) + extProcPb.RegisterExternalProcessorServer( + srv, + handlers.NewServer(), + ) + + // Forward to the gRPC runnable. + return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) + })) +} + +func createSelfSignedTLSCertificate(logger logr.Logger) (tls.Certificate, error) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + logger.Error(err, "Failed to create serial number for self-signed cert") + return tls.Certificate{}, err + } + now := time.Now() + notBefore := now.UTC() + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Inference Ext"}, + }, + NotBefore: notBefore, + NotAfter: now.Add(time.Hour * 24 * 365 * 10).UTC(), // 10 years + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + logger.Error(err, "Failed to generate key for self-signed cert") + return tls.Certificate{}, err + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + logger.Error(err, "Failed to create self-signed certificate") + return tls.Certificate{}, err + } + + certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + logger.Error(err, "Failed to marshal private key for self-signed certificate") + return tls.Certificate{}, err + } + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + + return tls.X509KeyPair(certBytes, keyBytes) +} From 45e95330ec5a3fc017231f9c1d1f9606529e3015 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Tue, 4 Mar 2025 11:27:43 -0500 Subject: [PATCH 070/260] Add Makefile + cloudbuild configs for body-based routing extension (#442) --- Makefile | 47 +++++++++++++++++++++++++++++++++++++++++++++++ cloudbuild.yaml | 8 ++++++++ 2 files changed, 55 insertions(+) diff --git a/Makefile b/Makefile index 8d02a5e8..61b17f5b 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ DOCKER_BUILDX_CMD ?= docker buildx IMAGE_BUILD_CMD ?= $(DOCKER_BUILDX_CMD) build IMAGE_BUILD_EXTRA_OPTS ?= SYNCER_IMAGE_BUILD_EXTRA_OPTS ?= +BBR_IMAGE_BUILD_EXTRA_OPTS ?= IMAGE_REGISTRY ?= us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension IMAGE_NAME := epp IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(IMAGE_NAME) @@ -36,6 +37,10 @@ SYNCER_IMAGE_NAME := lora-syncer SYNCER_IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(SYNCER_IMAGE_NAME) SYNCER_IMAGE_TAG ?= $(SYNCER_IMAGE_REPO):$(GIT_TAG) +BBR_IMAGE_NAME := bbr +BBR_IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(BBR_IMAGE_NAME) +BBR_IMAGE_TAG ?= $(BBR_IMAGE_REPO):$(GIT_TAG) + BASE_IMAGE ?= gcr.io/distroless/static:nonroot BUILDER_IMAGE ?= golang:1.23 ifdef GO_VERSION @@ -45,10 +50,12 @@ endif ifdef EXTRA_TAG IMAGE_EXTRA_TAG ?= $(IMAGE_REPO):$(EXTRA_TAG) SYNCER_IMAGE_EXTRA_TAG ?= $(SYNCER_IMAGE_REPO):$(EXTRA_TAG) +BBR_IMAGE_EXTRA_TAG ?= $(BBR_IMAGE_REPO):$(EXTRA_TAG) endif ifdef IMAGE_EXTRA_TAG IMAGE_BUILD_EXTRA_OPTS += -t $(IMAGE_EXTRA_TAG) SYNCER_IMAGE_BUILD_EXTRA_OPTS += -t $(SYNCER_IMAGE_EXTRA_TAG) +BBR_IMAGE_BUILD_EXTRA_OPTS += -t $(BBR_IMAGE_EXTRA_TAG) endif # The name of the kind cluster to use for the "kind-load" target. @@ -203,6 +210,46 @@ syncer-image-build: syncer-image-push: PUSH=--push syncer-image-push: syncer-image-build +##@ Body-based Routing extension + +# Build the container image +.PHONY: bbr-image-local-build +bbr-image-local-build: ## Build the image using Docker Buildx for local development. + BUILDER=$(shell $(DOCKER_BUILDX_CMD) create --use) + $(MAKE) bbr-image-build PUSH=$(PUSH) + $(MAKE) bbr-image-build LOAD=$(LOAD) + $(DOCKER_BUILDX_CMD) rm $$BUILDER + +.PHONY: bbr-image-local-push +bbr-image-local-push: PUSH=--push ## Build the image for local development and push it to $IMAGE_REPO. +bbr-image-local-push: bbr-image-local-build + +.PHONY: bbr-image-local-load +bbr-image-local-load: LOAD=--load ## Build the image for local development and load it in the local Docker registry. +bbr-image-local-load: bbr-image-local-build + +.PHONY: bbr-image-build +bbr-image-build: ## Build the image using Docker Buildx. + $(IMAGE_BUILD_CMD) -f body-based-routing.Dockerfile -t $(BBR_IMAGE_TAG) \ + --platform=$(PLATFORMS) \ + --build-arg BASE_IMAGE=$(BASE_IMAGE) \ + --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ + $(PUSH) \ + $(LOAD) \ + . + +.PHONY: bbr-image-push +bbr-image-push: PUSH=--push ## Build the image and push it to $IMAGE_REPO. +bbr-image-push: bbr-image-build + +.PHONY: bbr-image-load +bbr-image-load: LOAD=--load ## Build the image and load it in the local Docker registry. +bbr-image-load: bbr-image-build + +.PHONY: bbr-image-kind +bbr-image-kind: bbr-image-build ## Build the image and load it to kind cluster $KIND_CLUSTER ("kind" by default). + kind load docker-image $(BBR_IMAGE_TAG) --name $(KIND_CLUSTER) + ##@ Docs .PHONY: build-docs diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 9b345c18..3a8e008f 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -20,6 +20,14 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint + - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc + entrypoint: make + args: + - bbr-image-push + env: + - GIT_TAG=$_GIT_TAG + - EXTRA_TAG=$_PULL_BASE_REF + - DOCKER_BUILDX_CMD=/buildx-entrypoint substitutions: # _GIT_TAG will be filled with a git-based tag for the image, of the form vYYYYMMDD-hash, and # can be used as a substitution From 7208cff5cca57989b1c346191e373eb143e16ed6 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 4 Mar 2025 19:57:49 +0200 Subject: [PATCH 071/260] added cpu based example (#436) * added cpu based example to quickstart Signed-off-by: Nir Rozenbaum * removed quickstart cleanup instructions Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- config/manifests/ext_proc.yaml | 6 +- config/manifests/inferencemodel.yaml | 2 +- config/manifests/vllm/cpu-deployment.yaml | 101 ++++++++++++++++++ .../{deployment.yaml => gpu-deployment.yaml} | 6 +- site-src/guides/index.md | 31 +++++- 5 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 config/manifests/vllm/cpu-deployment.yaml rename config/manifests/vllm/{deployment.yaml => gpu-deployment.yaml} (97%) diff --git a/config/manifests/ext_proc.yaml b/config/manifests/ext_proc.yaml index f96113e1..60a0fc3e 100644 --- a/config/manifests/ext_proc.yaml +++ b/config/manifests/ext_proc.yaml @@ -44,11 +44,11 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: labels: - name: vllm-llama2-7b-pool + name: my-pool spec: targetPortNumber: 8000 selector: - app: vllm-llama2-7b-pool + app: my-pool extensionRef: name: inference-gateway-ext-proc --- @@ -75,7 +75,7 @@ spec: imagePullPolicy: Always args: - -poolName - - "vllm-llama2-7b-pool" + - "my-pool" - -v - "3" - -grpcPort diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 57240298..94c36d84 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -6,7 +6,7 @@ spec: modelName: tweet-summary criticality: Critical poolRef: - name: vllm-llama2-7b-pool + name: my-pool targetModels: - name: tweet-summary-1 weight: 100 diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml new file mode 100644 index 00000000..a0925c83 --- /dev/null +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -0,0 +1,101 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-pool +spec: + replicas: 3 + selector: + matchLabels: + app: my-pool + template: + metadata: + labels: + app: my-pool + spec: + containers: + - name: lora + image: "seedjeffwan/vllm-cpu-env:bb392af4-20250203" + imagePullPolicy: Always + command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] + args: + - "--model" + - "Qwen/Qwen2.5-1.5B-Instruct" + - "--port" + - "8000" + - "--enable-lora" + - "--lora-modules" + - '{"name": "tweet-summary-0", "path": "/adapters/hub/models--ai-blond--Qwen-Qwen2.5-Coder-1.5B-Instruct-lora/snapshots/9cde18d8ed964b0519fb481cca6acd936b2ca811"}' + - '{"name": "tweet-summary-1", "path": "/adapters/hub/models--ai-blond--Qwen-Qwen2.5-Coder-1.5B-Instruct-lora/snapshots/9cde18d8ed964b0519fb481cca6acd936b2ca811"}' + env: + - name: PORT + value: "8000" + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: token + - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING + value: "true" + ports: + - containerPort: 8000 + name: http + protocol: TCP + livenessProbe: + failureThreshold: 240 + httpGet: + path: /health + port: http + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 + readinessProbe: + failureThreshold: 600 + httpGet: + path: /health + port: http + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 + volumeMounts: + - mountPath: /data + name: data + - mountPath: /dev/shm + name: shm + - name: adapters + mountPath: "/adapters" + initContainers: + - name: adapter-loader + image: ghcr.io/tomatillo-and-multiverse/adapter-puller:demo + command: ["python"] + args: + - ./pull_adapters.py + - --adapter + - ai-blond/Qwen-Qwen2.5-Coder-1.5B-Instruct-lora + - --duplicate-count + - "4" + env: + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: token + - name: HF_HOME + value: /adapters + volumeMounts: + - name: adapters + mountPath: "/adapters" + restartPolicy: Always + schedulerName: default-scheduler + terminationGracePeriodSeconds: 30 + volumes: + - name: data + emptyDir: {} + - name: shm + emptyDir: + medium: Memory + - name: adapters + emptyDir: {} diff --git a/config/manifests/vllm/deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml similarity index 97% rename from config/manifests/vllm/deployment.yaml rename to config/manifests/vllm/gpu-deployment.yaml index 51689c9f..d16a46a4 100644 --- a/config/manifests/vllm/deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -1,16 +1,16 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: vllm-llama2-7b-pool + name: my-pool spec: replicas: 3 selector: matchLabels: - app: vllm-llama2-7b-pool + app: my-pool template: metadata: labels: - app: vllm-llama2-7b-pool + app: my-pool spec: containers: - name: lora diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 2949d387..976368ac 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -5,19 +5,40 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ## **Prerequisites** - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - A cluster with: - - Support for Services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, - you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - - 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/deployment.yaml` as needed. + - Support for services of typs `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). + For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). ## **Steps** ### Deploy Sample Model Server + This quickstart guide contains two options for setting up model server: + + 1. GPU-based model server. + Requirements: a Hugging Face access token that grants access to the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). + + 1. CPU-based model server (not using GPUs). + Requirements: a Hugging Face access token that grants access to the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). + + Choose one of these options and follow the steps below. Please do not deploy both, as the deployments have the same name and will override each other. + +#### GPU-Based Model Server + + For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/deployment.yaml` as needed. Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/deployment.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml + ``` + +#### CPU-Based Model Server + + Create a Hugging Face secret to download the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). Ensure that the token grants access to this model. + Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. + ```bash + kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Qwen + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml ``` ### Install the Inference Extension CRDs @@ -49,7 +70,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gateway.yaml ``` - > **_NOTE:_** This file couples together the gateway infra and the HTTPRoute infra for a convenient, quick startup. Creating additional/different InferencePools on the same gateway will require an additional set of: `Backend`, `HTTPRoute`, the resources included in the `./manifests/gateway/ext-proc.yaml` file, and an additional `./manifests/gateway/patch_policy.yaml` file. ***Should you choose to experiment, familiarity with xDS and Envoy are very useful.*** + > **_NOTE:_** This file couples together the gateway infra and the HTTPRoute infra for a convenient, quick startup. Creating additional/different InferencePools on the same gateway will require an additional set of: `Backend`, `HTTPRoute`, the resources included in the `./config/manifests/gateway/ext-proc.yaml` file, and an additional `./config/manifests/gateway/patch_policy.yaml` file. ***Should you choose to experiment, familiarity with xDS and Envoy are very useful.*** Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: ```bash From 61185343a1a63edc96209425309a87a7051c804a Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 4 Mar 2025 20:11:44 +0200 Subject: [PATCH 072/260] updated cleanup section in quickstart (#448) Signed-off-by: Nir Rozenbaum --- site-src/guides/index.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 976368ac..98ae94a3 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -114,3 +114,22 @@ This quickstart guide is intended for engineers familiar with k8s and model serv "temperature": 0 }' ``` + +### Cleanup + + The following cleanup assumes you would like to clean ALL resources that were created in this quickstart guide. + please be careful not to delete resources you'd like to keep. + ```bash + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/traffic_policy.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/extension_policy.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/patch_policy.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/ext_proc.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gateway.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/enable_patch_policy.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml --ignore-not-found + kubectl delete secret hf-token --ignore-not-found + ``` \ No newline at end of file From dfe8d9c62c0e06b522010d56fcfcb100014d8e85 Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Tue, 4 Mar 2025 10:37:43 -0800 Subject: [PATCH 073/260] scheduling changes for lora affinity load balancing (#423) * scheduling changes for lora affinity load balancing * refactor unit tests, address comments * restore vllm deployment manifest * update README for model server protocol to add waiting lora adapters * remove unused variables * removed unused func * fix model protocol readme * fix hermetic test for select active lora, low queue * update comment in metrics.go in vllm backend * add filter test TestLoRASoftAffinityDistribution * restore vllm manifest * update unit test --- .../003-model-server-protocol/README.md | 1 + pkg/epp/backend/vllm/metrics.go | 45 +++++++++- pkg/epp/scheduling/filter.go | 61 +++++++++++-- pkg/epp/scheduling/filter_test.go | 90 +++++++++++++++++++ pkg/epp/scheduling/scheduler.go | 20 ++--- test/integration/hermetic_test.go | 3 +- 6 files changed, 196 insertions(+), 24 deletions(-) diff --git a/docs/proposals/003-model-server-protocol/README.md b/docs/proposals/003-model-server-protocol/README.md index 2ab557f7..02efbe5c 100644 --- a/docs/proposals/003-model-server-protocol/README.md +++ b/docs/proposals/003-model-server-protocol/README.md @@ -47,3 +47,4 @@ The model server MUST expose the following LoRA adapter metrics via the same Pro requested adapter. Example: `"max_lora": "8"`. * `running_lora_adapters`: A comma separated list of adapters that are currently loaded in GPU memory and ready to serve requests. Example: `"running_lora_adapters": "adapter1, adapter2"` + * `waiting_lora_adapters`: A comma separated list of adapters that are waiting to be served. Example: `"waiting_lora_adapters": "adapter1, adapter2"` diff --git a/pkg/epp/backend/vllm/metrics.go b/pkg/epp/backend/vllm/metrics.go index 4973c93e..5b36b930 100644 --- a/pkg/epp/backend/vllm/metrics.go +++ b/pkg/epp/backend/vllm/metrics.go @@ -34,9 +34,13 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +// Metric names used in the vLLM metrics implementation. +// Refer to the protocol doc for more details: +// https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol const ( LoraRequestInfoMetricName = "vllm:lora_requests_info" LoraRequestInfoRunningAdaptersMetricName = "running_lora_adapters" + LoraRequestInfoWaitingAdaptersMetricName = "waiting_lora_adapters" LoraRequestInfoMaxAdaptersMetricName = "max_lora" // TODO: Replace these with the num_tokens_running/waiting below once we add those to the fork. RunningQueueSizeMetricName = "vllm:num_requests_running" @@ -45,8 +49,7 @@ const ( RunningQueueSizeMetricName = "vllm:num_tokens_running" WaitingQueueSizeMetricName = "vllm:num_tokens_waiting" */ - KVCacheUsagePercentMetricName = "vllm:gpu_cache_usage_perc" - KvCacheMaxTokenCapacityMetricName = "vllm:gpu_cache_max_token_capacity" + KVCacheUsagePercentMetricName = "vllm:gpu_cache_usage_perc" ) type PodMetricsClientImpl struct{} @@ -138,6 +141,14 @@ func promToPodMetrics( } } } + if label.GetName() == LoraRequestInfoWaitingAdaptersMetricName { + if label.GetValue() != "" { + adapterList := strings.Split(label.GetValue(), ",") + for _, adapter := range adapterList { + updated.ActiveModels[adapter] = 0 + } + } + } if label.GetName() == LoraRequestInfoMaxAdaptersMetricName { if label.GetValue() != "" { updated.MaxActiveModels, err = strconv.Atoi(label.GetValue()) @@ -163,14 +174,42 @@ func getLatestLoraMetric(logger logr.Logger, metricFamilies map[string]*dto.Metr logger.V(logutil.DEFAULT).Error(nil, "Metric family not found", "name", LoraRequestInfoMetricName) return nil, time.Time{}, fmt.Errorf("metric family %q not found", LoraRequestInfoMetricName) } - var latestTs float64 + var latest *dto.Metric + var latestTs float64 + + // Iterate over all metrics in the family. for _, m := range loraRequests.GetMetric() { + var running, waiting string + // Read the label values for running and waiting adapters. + for _, lp := range m.GetLabel() { + switch lp.GetName() { + case LoraRequestInfoRunningAdaptersMetricName: + running = lp.GetValue() + case LoraRequestInfoWaitingAdaptersMetricName: + waiting = lp.GetValue() + } + } + + // Ignore metrics with both labels empty. This happens when there are no running or waiting requests on + // the server, in this case it is best to use the last set of active adapters. + if running == "" && waiting == "" { + continue + } + + // Select the metric with the latest creation timestamp. if m.GetGauge().GetValue() > latestTs { latestTs = m.GetGauge().GetValue() latest = m } } + + if latest == nil { + logger.V(logutil.TRACE).Info("Metric value Empty", "value", latest, "metric", LoraRequestInfoMetricName) + return nil, time.Time{}, nil + } + + // Convert the gauge value (creation timestamp) to time.Time. return latest, time.Unix(0, int64(latestTs*1000)), nil } diff --git a/pkg/epp/scheduling/filter.go b/pkg/epp/scheduling/filter.go index b7881468..d3c22673 100644 --- a/pkg/epp/scheduling/filter.go +++ b/pkg/epp/scheduling/filter.go @@ -19,6 +19,8 @@ package scheduling import ( "errors" "math" + "math/rand" + "time" "github.com/go-logr/logr" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" @@ -183,18 +185,59 @@ func lowLoRACostPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { return ok || len(pod.ActiveModels) < pod.MaxActiveModels } -// loRAAffinityPredicate is a filter function to check whether a pod has affinity to the lora requested. -func loRAAffinityPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { - _, ok := pod.ActiveModels[req.ResolvedTargetModel] - return ok -} +// loRASoftAffinityPredicate implements a pod selection strategy that prioritizes pods +// with existing LoRA model affinity while allowing for load balancing through randomization. +// +// The function works by: +// 1. Separating pods into two groups: those with target model affinity and those with available capacity +// 2. Using a probability threshold to sometimes select from non-affinity pods to enable load balancing +// 3. Falling back to whatever group has pods if one group is empty +// +// Parameters: +// - logger: Logger interface for diagnostic output +// - req: LLM request containing the resolved target model +// - pods: Slice of pod metrics to filter +// +// Returns: +// - Filtered slice of pod metrics based on affinity and availability +// - Error if any issues occur during filtering +func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { + + // Pre-allocate slices with estimated capacity + filtered_affinity := make([]*datastore.PodMetrics, 0, len(pods)) + filtered_available := make([]*datastore.PodMetrics, 0, len(pods)) + + // Categorize pods based on affinity and availability + for _, pod := range pods { + + if _, exists := pod.ActiveModels[req.ResolvedTargetModel]; exists { + filtered_affinity = append(filtered_affinity, pod) + } else if len(pod.ActiveModels) < pod.MaxActiveModels { + filtered_available = append(filtered_available, pod) + } + } + + // Use crypto/rand for better randomization in production environments + randSource := rand.NewSource(time.Now().UnixNano()) + randGen := rand.New(randSource) + + // If both groups have pods, use probability to select which group to return + if len(filtered_affinity) > 0 && len(filtered_available) > 0 { + if randGen.Float64() < loraAffinityThreshold { + return filtered_affinity, nil + } + return filtered_available, nil + } + + // Return whichever group has pods + if len(filtered_affinity) > 0 { + return filtered_affinity, nil + } -// canAcceptNewLoraPredicate is a filter function to check whether a pod has room to load the adapter. -func canAcceptNewLoraPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { - return len(pod.ActiveModels) < pod.MaxActiveModels + return filtered_available, nil } -func criticalRequestPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { +func criticalRequestPredicate(req *LLMRequest, _ *datastore.PodMetrics) bool { return req.Critical } diff --git a/pkg/epp/scheduling/filter_test.go b/pkg/epp/scheduling/filter_test.go index ac765b78..f76cece9 100644 --- a/pkg/epp/scheduling/filter_test.go +++ b/pkg/epp/scheduling/filter_test.go @@ -429,3 +429,93 @@ func TestFilterFunc(t *testing.T) { }) } } + +// TestLoRASoftAffinityDistribution tests that the loRASoftAffinityFilter function +// properly distributes requests according to the loraAffinityThreshold +func TestLoRASoftAffinityDistribution(t *testing.T) { + logger := logutil.NewTestLogger() + + const ( + testModelName = "test-model" + testAffinityModel = "test-affinity-model" + numIterations = 10000 + tolerancePercent = 5.0 // Allow 5% tolerance from expected distribution + ) + + // Create a test request and pods + req := &LLMRequest{ + Model: testAffinityModel, + ResolvedTargetModel: testAffinityModel, + } + + // Test setup: One affinity pod and one available pod + pods := []*datastore.PodMetrics{ + { + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "affinity-pod"}}, + Metrics: datastore.Metrics{ + MaxActiveModels: 2, + ActiveModels: map[string]int{ + testAffinityModel: 1, + }, + }, + }, + { + Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "available-pod"}}, + Metrics: datastore.Metrics{ + MaxActiveModels: 2, + ActiveModels: map[string]int{}, + }, + }, + } + + // Run the filter function multiple times and count the results + affinityCount := 0 + availableCount := 0 + + // Use the actual loraAffinityThreshold as defined in the original code + // This test should work with whatever value is set there + expectedAffinityPercent := loraAffinityThreshold * 100 + for i := 0; i < numIterations; i++ { + result, err := loRASoftAffinityFilter(logger, req, pods) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Check which type of pod was returned + if len(result) != 1 { + t.Fatalf("Expected exactly one pod in result, got %d", len(result)) + } + + // Identify if the returned pod is the affinity pod or available pod + if _, exists := result[0].ActiveModels[testAffinityModel]; exists { + affinityCount++ + } else { + availableCount++ + } + } + + // Calculate the actual percentages + actualAffinityPercent := float64(affinityCount) / float64(numIterations) * 100 + actualAvailablePercent := float64(availableCount) / float64(numIterations) * 100 + + // Check if the distribution matches expected threshold within tolerance + affinityLowerBound := expectedAffinityPercent - tolerancePercent + affinityUpperBound := expectedAffinityPercent + tolerancePercent + + availableLowerBound := actualAvailablePercent - tolerancePercent + availableUpperBound := actualAvailablePercent + tolerancePercent + + t.Logf("Distribution results over %d iterations:", numIterations) + t.Logf("Expected affinity percent: %.2f%% (threshold: %.2f)", expectedAffinityPercent, loraAffinityThreshold) + t.Logf("Actual affinity percent: %.2f%% (%d out of %d)", actualAffinityPercent, affinityCount, numIterations) + t.Logf("Actual available percent: %.2f%% (%d out of %d)", actualAvailablePercent, availableCount, numIterations) + + if actualAffinityPercent < affinityLowerBound || actualAffinityPercent > affinityUpperBound { + t.Errorf("Affinity selection percent %.2f%% outside expected range %.2f%% to %.2f%%", + actualAffinityPercent, affinityLowerBound, affinityUpperBound) + } + if actualAvailablePercent < availableLowerBound || actualAvailablePercent > availableUpperBound { + t.Errorf("Availability selection percent %.2f%% outside expected range %.2f%% to %.2f%%", + actualAvailablePercent, availableLowerBound, availableUpperBound) + } +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index a969948e..bdddd972 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -36,8 +36,11 @@ const ( queueThresholdCritical = 5 // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16) Make this configurable. // the threshold for queued requests to be considered low below which we can prioritize LoRA affinity. - // The value of 50 is arrived heuristicically based on experiments. - queueingThresholdLoRA = 50 + // The value of 128 is arrived heuristicically based on experiments. + queueingThresholdLoRA = 128 + // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16) Make this configurable. + // loraAffinityThreshold indicates the probability with which we prefer a pod with LoRA affinity over a pod without but having room to fit more LoRA adapters. + loraAffinityThreshold = 0.999 ) var ( @@ -54,7 +57,7 @@ var ( filter: leastQueuingFilterFunc, nextOnSuccessOrFailure: &filter{ name: "low cost LoRA", - filter: toFilterFunc(lowLoRACostPredicate), + filter: loRASoftAffinityFilter, nextOnSuccessOrFailure: &filter{ name: "least KV cache percent", filter: leastKVCacheFilterFunc, @@ -76,14 +79,9 @@ var ( name: "low queueing filter", filter: toFilterFunc((lowQueueingPodPredicate)), nextOnSuccess: &filter{ - name: "affinity LoRA", - filter: toFilterFunc(loRAAffinityPredicate), - nextOnSuccess: queueAndKVCacheFilter, - nextOnFailure: &filter{ - name: "can accept LoRA Adapter", - filter: toFilterFunc(canAcceptNewLoraPredicate), - nextOnSuccessOrFailure: queueAndKVCacheFilter, - }, + name: "affinity LoRA", + filter: loRASoftAffinityFilter, + nextOnSuccessOrFailure: queueAndKVCacheFilter, }, nextOnFailure: queueLoRAAndKVCacheFilter, } diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index 7755795b..cc836504 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -158,6 +158,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ "foo": 1, + "bar": 1, }, }), }, @@ -200,7 +201,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }), extprocutils.FakePodMetrics(1, datastore.Metrics{ - WaitingQueueSize: 50, + WaitingQueueSize: 200, KVCacheUsagePercent: 0.1, ActiveModels: map[string]int{ "foo": 1, From 48978f4e9ccb524a0c4942e7480a197dbeeba2c3 Mon Sep 17 00:00:00 2001 From: Rob Scott Date: Tue, 4 Mar 2025 12:59:45 -0800 Subject: [PATCH 074/260] Fixing default status on InferencePool (#449) --- api/v1alpha2/inferencepool_types.go | 5 +++-- .../bases/inference.networking.x-k8s.io_inferencepools.yaml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index 19ec799f..e4350417 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -168,13 +168,14 @@ type PoolStatus struct { // // Known condition types are: // - // * "Ready" + // * "Accepted" + // * "ResolvedRefs" // // +optional // +listType=map // +listMapKey=type // +kubebuilder:validation:MaxItems=8 - // +kubebuilder:default={{type: "Ready", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}} + // +kubebuilder:default={{type: "Accepted", status: "Unknown", reason:"Pending", message:"Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}} Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml index 5767508b..8386db82 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml @@ -154,13 +154,14 @@ spec: message: Waiting for controller reason: Pending status: Unknown - type: Ready + type: Accepted description: |- Conditions track the state of the InferencePool. Known condition types are: - * "Ready" + * "Accepted" + * "ResolvedRefs" items: description: Condition contains details for one aspect of the current state of this API Resource. From 9bd981b09bd144d3f97830e9c0aa763c29516419 Mon Sep 17 00:00:00 2001 From: Tiger Xu / Zhonghu Xu Date: Thu, 6 Mar 2025 01:35:45 +0800 Subject: [PATCH 075/260] Use server side namespace filter (#429) * Add filter to pod redconciler * use poolHasSynced * filter using server-side namespace filter * update object filter * update * remove unused scheme and namespace * Move controller manager build function to pkg/epp/server so we can better test it * Update integration test --- cmd/epp/main.go | 14 +--- .../controller/inferencemodel_reconciler.go | 4 +- .../inferencemodel_reconciler_test.go | 1 - .../controller/inferencepool_reconciler.go | 6 -- pkg/epp/controller/pod_reconciler.go | 9 +-- pkg/epp/server/controller_manager.go | 73 +++++++++++++++++++ pkg/epp/server/runserver.go | 3 - test/integration/hermetic_test.go | 4 +- 8 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 pkg/epp/server/controller_manager.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index ab270c49..5b350bb2 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -30,16 +30,12 @@ import ( "go.uber.org/zap/zapcore" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/component-base/metrics/legacyregistry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/vllm" @@ -97,15 +93,9 @@ var ( "are assumed to be named tls.crt and tls.key, respectively. If not set, and secureServing is enabled, "+ "then a self-signed certificate is used.") - scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") ) -func init() { - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha2.AddToScheme(scheme)) -} - func main() { if err := run(); err != nil { os.Exit(1) @@ -140,9 +130,9 @@ func run() error { return err } - mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) + mgr, err := runserver.NewDefaultManager(*poolNamespace, *poolName, cfg) if err != nil { - setupLog.Error(err, "Failed to create controller manager", "config", cfg) + setupLog.Error(err, "Failed to create controller manager") return err } diff --git a/pkg/epp/controller/inferencemodel_reconciler.go b/pkg/epp/controller/inferencemodel_reconciler.go index 8318324f..a7f365b7 100644 --- a/pkg/epp/controller/inferencemodel_reconciler.go +++ b/pkg/epp/controller/inferencemodel_reconciler.go @@ -21,7 +21,6 @@ import ( "fmt" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" @@ -36,7 +35,6 @@ import ( type InferenceModelReconciler struct { client.Client - Scheme *runtime.Scheme Record record.EventRecorder Datastore datastore.Datastore PoolNamespacedName types.NamespacedName @@ -128,5 +126,5 @@ func (c *InferenceModelReconciler) SetupWithManager(ctx context.Context, mgr ctr } func (c *InferenceModelReconciler) eventPredicate(infModel *v1alpha2.InferenceModel) bool { - return (infModel.Spec.PoolRef.Name == v1alpha2.ObjectName(c.PoolNamespacedName.Name)) && (infModel.GetNamespace() == c.PoolNamespacedName.Namespace) + return string(infModel.Spec.PoolRef.Name) == c.PoolNamespacedName.Name } diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index d5277919..2ac5bb1e 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -193,7 +193,6 @@ func TestInferenceModelReconciler(t *testing.T) { datastore := datastore.NewFakeDatastore(nil, test.modelsInStore, pool) reconciler := &InferenceModelReconciler{ Client: fakeClient, - Scheme: scheme, Record: record.NewFakeRecorder(10), Datastore: datastore, PoolNamespacedName: types.NamespacedName{Name: pool.Name, Namespace: pool.Namespace}, diff --git a/pkg/epp/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go index 2ad7d2bb..880aec8c 100644 --- a/pkg/epp/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -21,13 +21,11 @@ import ( "reflect" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -38,7 +36,6 @@ import ( // will have the proper controller that will create/manage objects on behalf of the server pool. type InferencePoolReconciler struct { client.Client - Scheme *runtime.Scheme Record record.EventRecorder PoolNamespacedName types.NamespacedName Datastore datastore.Datastore @@ -90,8 +87,5 @@ func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool * func (c *InferencePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha2.InferencePool{}). - WithEventFilter(predicate.NewPredicateFuncs(func(object client.Object) bool { - return (object.GetNamespace() == c.PoolNamespacedName.Namespace) && (object.GetName() == c.PoolNamespacedName.Name) - })). Complete(c) } diff --git a/pkg/epp/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go index 717d9f60..a6c897c2 100644 --- a/pkg/epp/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -22,7 +22,6 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" @@ -35,19 +34,15 @@ import ( type PodReconciler struct { client.Client Datastore datastore.Datastore - Scheme *runtime.Scheme Record record.EventRecorder } func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) - inferencePool, err := c.Datastore.PoolGet() - if err != nil { - logger.V(logutil.TRACE).Info("Skipping reconciling Pod because the InferencePool is not available yet", "error", err) + if !c.Datastore.PoolHasSynced() { + logger.V(logutil.TRACE).Info("Skipping reconciling Pod because the InferencePool is not available yet") // When the inferencePool is initialized it lists the appropriate pods and populates the datastore, so no need to requeue. return ctrl.Result{}, nil - } else if inferencePool.Namespace != req.Namespace { - return ctrl.Result{}, nil } logger.V(logutil.VERBOSE).Info("Pod being reconciled", "name", req.NamespacedName) diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go new file mode 100644 index 00000000..fd505d00 --- /dev/null +++ b/pkg/epp/server/controller_manager.go @@ -0,0 +1,73 @@ +/* +Copyright 2025 The Kubernetes 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 server + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.AddToScheme(scheme)) +} + +// NewDefaultManager creates a new controller manager with default configuration. +func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Manager, error) { + manager, err := ctrl.NewManager(restConfig, ctrl.Options{ + Scheme: scheme, + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &corev1.Pod{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + &v1alpha2.InferencePool{}: { + Namespaces: map[string]cache.Config{ + namespace: { + FieldSelector: fields.SelectorFromSet(fields.Set{ + "metadata.name": name, + }), + }, + }, + }, + &v1alpha2.InferenceModel{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create controller manager: %v", err) + } + return manager, nil +} diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index f3d9b6ac..4c0a7e53 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -89,7 +89,6 @@ func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Man // Create the controllers and register them with the manager if err := (&controller.InferencePoolReconciler{ Datastore: r.Datastore, - Scheme: mgr.GetScheme(), Client: mgr.GetClient(), PoolNamespacedName: types.NamespacedName{ Name: r.PoolName, @@ -102,7 +101,6 @@ func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Man if err := (&controller.InferenceModelReconciler{ Datastore: r.Datastore, - Scheme: mgr.GetScheme(), Client: mgr.GetClient(), PoolNamespacedName: types.NamespacedName{ Name: r.PoolName, @@ -115,7 +113,6 @@ func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Man if err := (&controller.PodReconciler{ Datastore: r.Datastore, - Scheme: mgr.GetScheme(), Client: mgr.GetClient(), Record: mgr.GetEventRecorderFor("pod"), }).SetupWithManager(mgr); err != nil { diff --git a/test/integration/hermetic_test.go b/test/integration/hermetic_test.go index cc836504..4fba7832 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/hermetic_test.go @@ -58,6 +58,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -491,7 +492,8 @@ func BeforeSuit(t *testing.T) func() { // Init runtime. ctrl.SetLogger(logger) - mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme}) + + mgr, err := server.NewDefaultManager("default", "vllm-llama2-7b-pool", cfg) if err != nil { logutil.Fatal(logger, err, "Failed to create controller manager") } From 5b823746a009da23cd6eacca68c1fb0a35be7bac Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 5 Mar 2025 19:53:47 +0200 Subject: [PATCH 076/260] fixed filepath that points to gpu based model server deployment in few places (#451) Signed-off-by: Nir Rozenbaum --- hack/release-quickstart.sh | 4 ++-- site-src/guides/index.md | 2 +- test/e2e/e2e_suite_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hack/release-quickstart.sh b/hack/release-quickstart.sh index a21047c3..832bd872 100755 --- a/hack/release-quickstart.sh +++ b/hack/release-quickstart.sh @@ -51,9 +51,9 @@ sed -i.bak '/us-central1-docker.pkg.dev\/k8s-staging-images\/gateway-api-inferen sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EXT_PROC" # ----------------------------------------------------------------------------- -# Update config/manifests/vllm/deployment.yaml +# Update config/manifests/vllm/gpu-deployment.yaml # ----------------------------------------------------------------------------- -VLLM_DEPLOY="config/manifests/vllm/deployment.yaml" +VLLM_DEPLOY="config/manifests/vllm/gpu-deployment.yaml" echo "Updating ${VLLM_DEPLOY} ..." # Update the vLLM image version diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 98ae94a3..b7b31000 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -24,7 +24,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv #### GPU-Based Model Server - For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/deployment.yaml` as needed. + For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/gpu-deployment.yaml` as needed. Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 3d068c9f..24a488db 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -69,7 +69,7 @@ const ( // clientManifest is the manifest for the client test resources. clientManifest = "../testdata/client.yaml" // modelServerManifest is the manifest for the model server test resources. - modelServerManifest = "../../config/manifests/vllm/deployment.yaml" + modelServerManifest = "../../config/manifests/vllm/gpu-deployment.yaml" // modelServerSecretManifest is the manifest for the model server secret resource. modelServerSecretManifest = "../testdata/model-secret.yaml" // inferPoolManifest is the manifest for the inference pool CRD. From 0aa142d79479f675d8d339f05a3275ce08d2308e Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Wed, 5 Mar 2025 17:55:44 -0500 Subject: [PATCH 077/260] Add library for generating self-signed cert (#453) --- internal/tls/tls.go | 73 ++++++++++++++++++++++ pkg/body-based-routing/server/runserver.go | 55 +--------------- pkg/epp/server/runserver.go | 54 +--------------- 3 files changed, 77 insertions(+), 105 deletions(-) create mode 100644 internal/tls/tls.go diff --git a/internal/tls/tls.go b/internal/tls/tls.go new file mode 100644 index 00000000..fb8092c6 --- /dev/null +++ b/internal/tls/tls.go @@ -0,0 +1,73 @@ +/* +Copyright 2025 The Kubernetes 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 tls + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "time" + + "github.com/go-logr/logr" +) + +// CreateSelfSignedTLSCertificate creates a self-signed cert the server can use to serve TLS. +func CreateSelfSignedTLSCertificate(logger logr.Logger) (tls.Certificate, error) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return tls.Certificate{}, fmt.Errorf("error creating serial number: %v", err) + } + now := time.Now() + notBefore := now.UTC() + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Inference Ext"}, + }, + NotBefore: notBefore, + NotAfter: now.Add(time.Hour * 24 * 365 * 10).UTC(), // 10 years + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return tls.Certificate{}, fmt.Errorf("error generating key: %v", err) + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("error creating certificate: %v", err) + } + + certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("error marshalling private key: %v", err) + } + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + + return tls.X509KeyPair(certBytes, keyBytes) +} diff --git a/pkg/body-based-routing/server/runserver.go b/pkg/body-based-routing/server/runserver.go index b04602bb..3674c6cf 100644 --- a/pkg/body-based-routing/server/runserver.go +++ b/pkg/body-based-routing/server/runserver.go @@ -18,14 +18,7 @@ package server import ( "context" - "crypto/rand" - "crypto/rsa" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/go-logr/logr" @@ -33,6 +26,7 @@ import ( "google.golang.org/grpc/credentials" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" + tlsutil "sigs.k8s.io/gateway-api-inference-extension/internal/tls" "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/handlers" ) @@ -56,7 +50,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { // The runnable implements LeaderElectionRunnable with leader election disabled. func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { - cert, err := createSelfSignedTLSCertificate(logger) + cert, err := tlsutil.CreateSelfSignedTLSCertificate(logger) if err != nil { logger.Error(err, "Failed to create self signed certificate") return err @@ -73,48 +67,3 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) })) } - -func createSelfSignedTLSCertificate(logger logr.Logger) (tls.Certificate, error) { - serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - if err != nil { - logger.Error(err, "Failed to create serial number for self-signed cert") - return tls.Certificate{}, err - } - now := time.Now() - notBefore := now.UTC() - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{"Inference Ext"}, - }, - NotBefore: notBefore, - NotAfter: now.Add(time.Hour * 24 * 365 * 10).UTC(), // 10 years - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - - priv, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - logger.Error(err, "Failed to generate key for self-signed cert") - return tls.Certificate{}, err - } - - derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) - if err != nil { - logger.Error(err, "Failed to create self-signed certificate") - return tls.Certificate{}, err - } - - certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - - privBytes, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - logger.Error(err, "Failed to marshal private key for self-signed certificate") - return tls.Certificate{}, err - } - keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) - - return tls.X509KeyPair(certBytes, keyBytes) -} diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 4c0a7e53..8c553cd5 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -18,14 +18,8 @@ package server import ( "context" - "crypto/rand" - "crypto/rsa" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" "fmt" - "math/big" "time" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" @@ -36,6 +30,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" + tlsutil "sigs.k8s.io/gateway-api-inference-extension/internal/tls" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/controller" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" @@ -139,7 +134,7 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { cert, err = tls.LoadX509KeyPair(r.CertPath+"/tls.crt", r.CertPath+"/tls.key") } else { // Create tls based credential. - cert, err = createSelfSignedTLSCertificate(logger) + cert, err = tlsutil.CreateSelfSignedTLSCertificate(logger) } if err != nil { logger.Error(err, "Failed to create self signed certificate") @@ -163,48 +158,3 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) })) } - -func createSelfSignedTLSCertificate(logger logr.Logger) (tls.Certificate, error) { - serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - if err != nil { - logger.Error(err, "Failed to create serial number for self-signed cert") - return tls.Certificate{}, err - } - now := time.Now() - notBefore := now.UTC() - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{"Inference Ext"}, - }, - NotBefore: notBefore, - NotAfter: now.Add(time.Hour * 24 * 365 * 10).UTC(), // 10 years - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - } - - priv, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - logger.Error(err, "Failed to generate key for self-signed cert") - return tls.Certificate{}, err - } - - derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) - if err != nil { - logger.Error(err, "Failed to create self-signed certificate") - return tls.Certificate{}, err - } - - certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - - privBytes, err := x509.MarshalPKCS8PrivateKey(priv) - if err != nil { - logger.Error(err, "Failed to marshal private key for self-signed certificate") - return tls.Certificate{}, err - } - keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) - - return tls.X509KeyPair(certBytes, keyBytes) -} From 70965a060bc7541f506aebd2065fb631b5045bfe Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 5 Mar 2025 17:09:37 -0700 Subject: [PATCH 078/260] Support full duplex streaming (#450) This PR supports the FULL_DUPLEX_STREAMED mode for ext-proc. --- cmd/epp/main.go | 6 + config/manifests/ext_proc.yaml | 5 +- .../manifests/gateway/extension_policy.yaml | 1 + config/manifests/gateway/patch_policy.yaml | 33 +- pkg/epp/handlers/server.go | 104 ++-- pkg/epp/handlers/streamingserver.go | 503 ++++++++++++++++++ pkg/epp/server/runserver.go | 11 +- 7 files changed, 613 insertions(+), 50 deletions(-) create mode 100644 pkg/epp/handlers/streamingserver.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 5b350bb2..1f62d94a 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -110,6 +110,11 @@ func run() error { flag.Parse() initLogging(&opts) + useStreamingServer, err := strconv.ParseBool(os.Getenv("USE_STREAMING")) + if err != nil { + setupLog.Error(err, "Failed to parse env var USE_STREAMING, defaulting to false") + } + // Validate flags if err := validateFlags(); err != nil { setupLog.Error(err, "Failed to validate flags") @@ -153,6 +158,7 @@ func run() error { SecureServing: *secureServing, CertPath: *certPath, Provider: provider, + UseStreaming: useStreamingServer, } if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { setupLog.Error(err, "Failed to setup ext-proc controllers") diff --git a/config/manifests/ext_proc.yaml b/config/manifests/ext_proc.yaml index 60a0fc3e..d70467ee 100644 --- a/config/manifests/ext_proc.yaml +++ b/config/manifests/ext_proc.yaml @@ -77,11 +77,14 @@ spec: - -poolName - "my-pool" - -v - - "3" + - "4" - -grpcPort - "9002" - -grpcHealthPort - "9003" + env: + - name: USE_STREAMING + value: "false" ports: - containerPort: 9002 - containerPort: 9003 diff --git a/config/manifests/gateway/extension_policy.yaml b/config/manifests/gateway/extension_policy.yaml index a8105d6d..14b7b123 100644 --- a/config/manifests/gateway/extension_policy.yaml +++ b/config/manifests/gateway/extension_policy.yaml @@ -11,6 +11,7 @@ spec: name: inference-gateway-ext-proc port: 9002 processingMode: + allowModeOverride: true request: body: Buffered response: diff --git a/config/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml index ae4fb6d8..3c36ed7a 100644 --- a/config/manifests/gateway/patch_policy.yaml +++ b/config/manifests/gateway/patch_policy.yaml @@ -48,10 +48,41 @@ spec: typed_config: "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext" common_tls_context: {} - - type: "type.googleapis.com/envoy.config.route.v3.RouteConfiguration" name: default/inference-gateway/llm-gw operation: op: replace path: "/virtual_hosts/0/routes/0/route/cluster" value: original_destination_cluster +# Uncomment the below to enable full duplex streaming + # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + # name: "default/inference-gateway/llm-gw" + # operation: + # op: add + # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_body_mode" + # value: FULL_DUPLEX_STREAMED + # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + # name: "default/inference-gateway/llm-gw" + # operation: + # op: add + # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_trailer_mode" + # value: SEND + # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + # name: "default/inference-gateway/llm-gw" + # operation: + # op: add + # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_body_mode" + # value: FULL_DUPLEX_STREAMED + # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + # name: "default/inference-gateway/llm-gw" + # operation: + # op: replace + # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_trailer_mode" + # value: SEND + # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + # name: "default/inference-gateway/llm-gw" + # operation: + # op: replace + # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_header_mode" + # value: SEND + diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 3270134b..bbdbe83e 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -132,53 +132,9 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { if err != nil { logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) - switch errutil.CanonicalCode(err) { - // This code can be returned by scheduler when there is no capacity for sheddable - // requests. - case errutil.InferencePoolResourceExhausted: - resp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extProcPb.ImmediateResponse{ - Status: &envoyTypePb.HttpStatus{ - Code: envoyTypePb.StatusCode_TooManyRequests, - }, - }, - }, - } - // This code can be returned by when EPP processes the request and run into server-side errors. - case errutil.Internal: - resp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extProcPb.ImmediateResponse{ - Status: &envoyTypePb.HttpStatus{ - Code: envoyTypePb.StatusCode_InternalServerError, - }, - }, - }, - } - // This code can be returned when users provide invalid json request. - case errutil.BadRequest: - resp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extProcPb.ImmediateResponse{ - Status: &envoyTypePb.HttpStatus{ - Code: envoyTypePb.StatusCode_BadRequest, - }, - }, - }, - } - case errutil.BadConfiguration: - resp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: &extProcPb.ImmediateResponse{ - Status: &envoyTypePb.HttpStatus{ - Code: envoyTypePb.StatusCode_NotFound, - }, - }, - }, - } - default: - return status.Errorf(status.Code(err), "failed to handle request: %v", err) + resp, err = BuildErrResponse(err) + if err != nil { + return err } } @@ -190,6 +146,60 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } +func BuildErrResponse(err error) (*extProcPb.ProcessingResponse, error) { + var resp *extProcPb.ProcessingResponse + + switch errutil.CanonicalCode(err) { + // This code can be returned by scheduler when there is no capacity for sheddable + // requests. + case errutil.InferencePoolResourceExhausted: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_TooManyRequests, + }, + }, + }, + } + // This code can be returned by when EPP processes the request and run into server-side errors. + case errutil.Internal: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_InternalServerError, + }, + }, + }, + } + // This code can be returned when users provide invalid json request. + case errutil.BadRequest: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_BadRequest, + }, + }, + }, + } + case errutil.BadConfiguration: + resp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_NotFound, + }, + }, + }, + } + default: + return nil, status.Errorf(status.Code(err), "failed to handle request: %v", err) + } + return resp, nil +} + // RequestContext stores context information during the life time of an HTTP request. type RequestContext struct { TargetPod string diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go new file mode 100644 index 00000000..821dd989 --- /dev/null +++ b/pkg/epp/handlers/streamingserver.go @@ -0,0 +1,503 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func NewStreamingServer(scheduler Scheduler, destinationEndpointHintMetadataNamespace, destinationEndpointHintKey string, datastore datastore.Datastore) *StreamingServer { + return &StreamingServer{ + scheduler: scheduler, + destinationEndpointHintMetadataNamespace: destinationEndpointHintMetadataNamespace, + destinationEndpointHintKey: destinationEndpointHintKey, + datastore: datastore, + } +} + +type StreamingServer struct { + scheduler Scheduler + // The key of the header to specify the target pod address. This value needs to match Envoy + // configuration. + destinationEndpointHintKey string + // The key acting as the outer namespace struct in the metadata extproc response to communicate + // back the picked endpoints. + destinationEndpointHintMetadataNamespace string + datastore datastore.Datastore +} + +func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { + ctx := srv.Context() + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Processing") + + // Create request context to share states during life time of an HTTP request. + // See https://github.com/envoyproxy/envoy/issues/17540. + reqCtx := &StreamingRequestContext{ + RequestState: RequestReceived, + } + + reader, writer := io.Pipe() + decoder := json.NewDecoder(reader) + + var requestBody, responseBody map[string]interface{} + // Create error handling var as each request should only report once for + // error metrics. This doesn't cover the error "Cannot receive stream request" because + // such errors might happen even though response is processed. + var err error + defer func(error) { + if reqCtx.ResponseStatusCode != "" { + metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseStatusCode) + } else if err != nil { + metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, errutil.CanonicalCode(err)) + } + }(err) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + req, recvErr := srv.Recv() + if recvErr == io.EOF || status.Code(recvErr) == codes.Canceled { + return nil + } + if recvErr != nil { + // This error occurs very frequently, though it doesn't seem to have any impact. + // TODO Figure out if we can remove this noise. + loggerVerbose.Error(err, "Cannot receive stream request") + return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) + } + + switch v := req.Request.(type) { + case *extProcPb.ProcessingRequest_RequestHeaders: + // Do nothing. Header info is handled in the HandleRequestBody func + case *extProcPb.ProcessingRequest_RequestBody: + loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) + // In the stream case, we can receive multiple request bodies. + // To buffer the full message, we create a goroutine with a writer.Write() + // call, which will block until the corresponding reader reads from it. + // We do not read until we receive the EndofStream signal, and then + // decode the entire JSON body. + go func() { + _, err := writer.Write(v.RequestBody.Body) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error populating writer") + } + }() + + // Message is buffered, we can read and decode. + if v.RequestBody.EndOfStream { + loggerVerbose.Info("decoding") + err = decoder.Decode(&requestBody) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + } + // Body stream complete. Close the reader pipe, and start anew for response. + reader.Close() + reader, writer = io.Pipe() + decoder = json.NewDecoder(reader) + + reqCtx, err = s.HandleRequestBody(ctx, reqCtx, req, requestBody) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error handling body") + } else { + metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) + metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) + } + loggerVerbose.Info("Request context after HandleRequestBody", "context", reqCtx) + } + case *extProcPb.ProcessingRequest_RequestTrailers: + // This is currently unused. + case *extProcPb.ProcessingRequest_ResponseHeaders: + loggerVerbose.Info("got response headers", "headers", v.ResponseHeaders.Headers.GetHeaders()) + for _, header := range v.ResponseHeaders.Headers.GetHeaders() { + code := header.RawValue[0] + if header.Key == "status" && string(code) != "200" { + reqCtx.ResponseStatusCode = errutil.ModelServerError + } + } + reqCtx.RequestState = ResponseRecieved + reqCtx.respHeaderResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + // This is for debugging purpose only. + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + }, + }, + }, + }, + }, + } + + case *extProcPb.ProcessingRequest_ResponseBody: + go func() { + _, err := writer.Write(v.ResponseBody.Body) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error populating writer") + } + }() + + // Message is buffered, we can read and decode. + if v.ResponseBody.EndOfStream { + err = decoder.Decode(&responseBody) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + } + // Body stream complete. Close the reader pipe. + reader.Close() + + reqCtx, err = s.HandleResponseBody(ctx, reqCtx, responseBody) + if err == nil && reqCtx.ResponseComplete { + reqCtx.ResponseCompleteTimestamp = time.Now() + metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) + metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) + metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) + metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) + } + loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) + } + case *extProcPb.ProcessingRequest_ResponseTrailers: + // This is currently unused. + } + + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) + resp, err := BuildErrResponse(err) + if err != nil { + return err + } + if err := srv.Send(resp); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Send failed") + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + return nil + } + loggerVerbose.Info("checking", "request state", reqCtx.RequestState) + if err := reqCtx.updateStateAndSendIfNeeded(srv, loggerVerbose); err != nil { + return err + } + } +} + +// updateStateAndSendIfNeeded checks state and can send mutiple responses in a single pass, but only if ordered properly. +// Order of requests matter in FULL_DUPLEX_STREAMING. For both request and response, the order of response sent back MUST be: Header->Body->Trailer, with trailer being optional. +func (r *StreamingRequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, loggerVerbose logr.Logger) error { + // No switch statement as we could send multiple responses in one pass. + if r.RequestState == RequestReceived && r.reqHeaderResp != nil { + loggerVerbose.Info("Request header response", "obj", r.reqHeaderResp) + if err := srv.Send(r.reqHeaderResp); err != nil { + loggerVerbose.Error(err, "error sending response") + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = HeaderRequestResponseComplete + } + if r.RequestState == HeaderRequestResponseComplete && r.reqBodyResp != nil { + loggerVerbose.Info("Request body response", "obj", r.reqBodyResp) + if err := srv.Send(r.reqBodyResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = BodyRequestResponsesComplete + // Dump the response so a new stream message can begin + r.reqBodyResp = nil + } + if r.RequestState == BodyRequestResponsesComplete && r.reqTrailerResp != nil { + // Trailers in requests are not guaranteed + if err := srv.Send(r.reqHeaderResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + } + if r.RequestState == ResponseRecieved && r.respHeaderResp != nil { + loggerVerbose.Info("Response header response", "obj", r.respHeaderResp) + if err := srv.Send(r.respHeaderResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = HeaderResponseResponseComplete + } + if r.RequestState == HeaderResponseResponseComplete && r.respBodyResp != nil { + loggerVerbose.Info("Response body response", "obj", r.respBodyResp) + if err := srv.Send(r.respBodyResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = BodyResponseResponsesComplete + // Dump the response so a new stream message can begin + r.reqBodyResp = nil + } + if r.RequestState == BodyResponseResponsesComplete && r.respTrailerResp != nil { + // Trailers in requests are not guaranteed + if err := srv.Send(r.reqHeaderResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + } + return nil +} + +type StreamingRequestContext struct { + TargetPod string + TargetEndpoint string + Model string + ResolvedTargetModel string + RequestState StreamRequestState + RequestReceivedTimestamp time.Time + ResponseCompleteTimestamp time.Time + RequestSize int + Usage Usage + ResponseSize int + ResponseComplete bool + ResponseStatusCode string + + reqHeaderResp *extProcPb.ProcessingResponse + reqBodyResp *extProcPb.ProcessingResponse + reqTrailerResp *extProcPb.ProcessingResponse + + respHeaderResp *extProcPb.ProcessingResponse + respBodyResp *extProcPb.ProcessingResponse + respTrailerResp *extProcPb.ProcessingResponse +} + +type StreamRequestState int + +const ( + RequestReceived StreamRequestState = 0 + HeaderRequestResponseComplete StreamRequestState = 1 + BodyRequestResponsesComplete StreamRequestState = 2 + TrailerRequestResponsesComplete StreamRequestState = 3 + ResponseRecieved StreamRequestState = 4 + HeaderResponseResponseComplete StreamRequestState = 5 + BodyResponseResponsesComplete StreamRequestState = 6 + TrailerResponseResponsesComplete StreamRequestState = 7 +) + +// HandleRequestBody always returns the requestContext even in the error case, as the request context is used in error handling. +func (s *StreamingServer) HandleRequestBody( + ctx context.Context, + reqCtx *StreamingRequestContext, + req *extProcPb.ProcessingRequest, + requestBodyMap map[string]interface{}, +) (*StreamingRequestContext, error) { + var requestBodyBytes []byte + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Handling request body") + + // Resolve target models. + model, ok := requestBodyMap["model"].(string) + if !ok { + return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} + } + loggerVerbose.Info("Model requested", "model", model) + modelName := model + + // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. + // This might be a security risk in the future where adapters not registered in the InferenceModel + // are able to be requested by using their distinct name. + modelObj := s.datastore.ModelGet(model) + if modelObj == nil { + return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} + } + if len(modelObj.Spec.TargetModels) > 0 { + modelName = datastore.RandomWeightedDraw(logger, modelObj, 0) + if modelName == "" { + return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} + } + } + llmReq := &scheduling.LLMRequest{ + Model: model, + ResolvedTargetModel: modelName, + Critical: datastore.IsCritical(modelObj), + } + loggerVerbose.Info("LLM request assembled", "request", llmReq) + + var err error + // Update target models in the body. + if llmReq.Model != llmReq.ResolvedTargetModel { + requestBodyMap["model"] = llmReq.ResolvedTargetModel + requestBodyBytes, err = json.Marshal(requestBodyMap) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") + return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} + } + loggerVerbose.Info("Updated request body marshalled", "body", string(requestBodyBytes)) + } + + targetPod, err := s.scheduler.Schedule(ctx, llmReq) + if err != nil { + return reqCtx, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} + } + + // Insert target endpoint to instruct Envoy to route requests to the specified target pod. + // Attach the port number + pool, err := s.datastore.PoolGet() + if err != nil { + return reqCtx, err + } + endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) + + logger.V(logutil.DEFAULT).Info("Request handled", + "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) + + reqCtx.Model = llmReq.Model + reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel + reqCtx.RequestSize = len(requestBodyBytes) + reqCtx.TargetPod = targetPod.NamespacedName.String() + reqCtx.TargetEndpoint = endpoint + + headers := []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: s.destinationEndpointHintKey, + RawValue: []byte(endpoint), + }, + }, + // We need to update the content length header if the body is mutated, see Envoy doc: + // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(len(requestBodyBytes))), + }, + }, + } + // Print headers for debugging + for _, header := range headers { + logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) + } + + targetEndpointValue := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + } + dynamicMetadata := targetEndpointValue + if s.destinationEndpointHintMetadataNamespace != "" { + // If a namespace is defined, wrap the selected endpoint with that. + dynamicMetadata = &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: targetEndpointValue, + }, + }, + }, + } + } + + reqCtx.reqHeaderResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: headers, + }, + }, + }, + }, + DynamicMetadata: dynamicMetadata, + } + reqCtx.reqBodyResp = &extProcPb.ProcessingResponse{ + // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header + // and as an unstructure ext-proc response metadata key/value pair. This enables different integration + // options for gateway providers. + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: requestBodyBytes, + EndOfStream: true, + }, + }, + }, + }, + }, + }, + } + return reqCtx, nil +} + +// HandleResponseBody always returns the requestContext even in the error case, as the request context is used in error handling. +func (s *StreamingServer) HandleResponseBody( + ctx context.Context, + reqCtx *StreamingRequestContext, + response map[string]interface{}, +) (*StreamingRequestContext, error) { + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Processing HandleResponseBody") + responseBytes, err := json.Marshal(response) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "error marshalling responseBody") + return reqCtx, err + } + if response["usage"] != nil { + usg := response["usage"].(map[string]interface{}) + usage := Usage{ + PromptTokens: int(usg["prompt_tokens"].(float64)), + CompletionTokens: int(usg["completion_tokens"].(float64)), + TotalTokens: int(usg["total_tokens"].(float64)), + } + reqCtx.Usage = usage + loggerVerbose.Info("Response generated", "usage", reqCtx.Usage) + } + reqCtx.ResponseSize = len(responseBytes) + // ResponseComplete is to indicate the response is complete. In non-streaming + // case, it will be set to be true once the response is processed; in + // streaming case, it will be set to be true once the last chunk is processed. + // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) + // will add the processing for streaming case. + reqCtx.ResponseComplete = true + + reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ + // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header + // and as an unstructure ext-proc response metadata key/value pair. This enables different integration + // options for gateway providers. + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: responseBytes, + EndOfStream: true, + }, + }, + }, + }, + }, + }, + } + return reqCtx, nil +} diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 8c553cd5..5b8269c1 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -51,6 +51,7 @@ type ExtProcServerRunner struct { Provider *backend.Provider SecureServing bool CertPath string + UseStreaming bool } // Default values for CLI flags in main @@ -149,9 +150,17 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { } else { srv = grpc.NewServer() } + var extProcServer extProcPb.ExternalProcessorServer + if r.UseStreaming { + logger.Info("Using streaming extproc server") + extProcServer = handlers.NewStreamingServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) + } else { + logger.Info("Using standard extproc server") + extProcServer = handlers.NewServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) + } extProcPb.RegisterExternalProcessorServer( srv, - handlers.NewServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore), + extProcServer, ) // Forward to the gRPC runnable. From b40de0474014ba9e17a5c63813b2583f0046725d Mon Sep 17 00:00:00 2001 From: Rob Scott Date: Wed, 5 Mar 2025 16:35:45 -0800 Subject: [PATCH 079/260] Renaming conditions and reasons used in InferencePool status (#454) --- api/v1alpha2/inferencepool_types.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index e4350417..b411dbe3 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -233,16 +233,16 @@ const ( // // Controllers MAY raise this condition with other reasons, but should // prefer to use the reasons listed above to improve interoperability. - ModelConditionResolvedRefs InferencePoolConditionType = "ResolvedRefs" + InferencePoolConditionResolvedRefs InferencePoolConditionType = "ResolvedRefs" // This reason is used with the "ResolvedRefs" condition when the condition // is true. - ModelReasonResolvedRefs InferencePoolReason = "ResolvedRefs" + InferencePoolReasonResolvedRefs InferencePoolReason = "ResolvedRefs" // This reason is used with the "ResolvedRefs" condition when the // ExtensionRef is invalid in some way. This can include an unsupported kind // or API group, or a reference to a resource that can not be found. - ModelReasonInvalidExtensionRef InferencePoolReason = "InvalidExtensionRef" + InferencePoolReasonInvalidExtensionRef InferencePoolReason = "InvalidExtensionRef" ) func init() { From 9079982a8c381984a0c357f674afc30836a9773c Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 6 Mar 2025 15:19:45 -0500 Subject: [PATCH 080/260] Move integration and e2e tests for epp into epp-specific directories (#457) --- test/e2e/{ => epp}/README.md | 0 test/e2e/{ => epp}/e2e_suite_test.go | 16 ++++++++-------- test/e2e/{ => epp}/e2e_test.go | 2 +- test/integration/{ => epp}/hermetic_test.go | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) rename test/e2e/{ => epp}/README.md (100%) rename test/e2e/{ => epp}/e2e_suite_test.go (96%) rename test/e2e/{ => epp}/e2e_test.go (99%) rename test/integration/{ => epp}/hermetic_test.go (98%) diff --git a/test/e2e/README.md b/test/e2e/epp/README.md similarity index 100% rename from test/e2e/README.md rename to test/e2e/epp/README.md diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go similarity index 96% rename from test/e2e/e2e_suite_test.go rename to test/e2e/epp/e2e_suite_test.go index 24a488db..e7685c48 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package e2e +package epp import ( "context" @@ -67,19 +67,19 @@ const ( // inferExtName is the name of the inference extension test resources. inferExtName = "inference-gateway-ext-proc" // clientManifest is the manifest for the client test resources. - clientManifest = "../testdata/client.yaml" + clientManifest = "../../testdata/client.yaml" // modelServerManifest is the manifest for the model server test resources. - modelServerManifest = "../../config/manifests/vllm/gpu-deployment.yaml" + modelServerManifest = "../../../config/manifests/vllm/gpu-deployment.yaml" // modelServerSecretManifest is the manifest for the model server secret resource. - modelServerSecretManifest = "../testdata/model-secret.yaml" + modelServerSecretManifest = "../../testdata/model-secret.yaml" // inferPoolManifest is the manifest for the inference pool CRD. - inferPoolManifest = "../../config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml" + inferPoolManifest = "../../../config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml" // inferModelManifest is the manifest for the inference model CRD. - inferModelManifest = "../../config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml" + inferModelManifest = "../../../config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml" // inferExtManifest is the manifest for the inference extension test resources. - inferExtManifest = "../../config/manifests/ext_proc.yaml" + inferExtManifest = "../../../config/manifests/ext_proc.yaml" // envoyManifest is the manifest for the envoy proxy test resources. - envoyManifest = "../testdata/envoy.yaml" + envoyManifest = "../../testdata/envoy.yaml" ) var ( diff --git a/test/e2e/e2e_test.go b/test/e2e/epp/e2e_test.go similarity index 99% rename from test/e2e/e2e_test.go rename to test/e2e/epp/e2e_test.go index 8cd73d32..f5cfaf24 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/epp/e2e_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package e2e +package epp import ( "fmt" diff --git a/test/integration/hermetic_test.go b/test/integration/epp/hermetic_test.go similarity index 98% rename from test/integration/hermetic_test.go rename to test/integration/epp/hermetic_test.go index 4fba7832..765449f3 100644 --- a/test/integration/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package test contains e2e tests for the ext proc while faking the backend pods. -package integration +// Package epp contains integration tests for the ext proc while faking the backend pods. +package epp import ( "bufio" @@ -472,7 +472,7 @@ func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (clie func BeforeSuit(t *testing.T) func() { // Set up mock k8s API Client testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, } cfg, err := testEnv.Start() @@ -522,7 +522,7 @@ func BeforeSuit(t *testing.T) func() { logger.Info("Setting up hermetic ExtProc server") // Unmarshal CRDs from file into structs - manifestsPath := filepath.Join("..", "testdata", "inferencepool-with-model-hermetic.yaml") + manifestsPath := filepath.Join("..", "..", "testdata", "inferencepool-with-model-hermetic.yaml") docs, err := readDocuments(manifestsPath) if err != nil { logutil.Fatal(logger, err, "Can't read object manifests", "path", manifestsPath) From 23bab8c4e0a991cbca3fe4dae24951a4a2b372fb Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Fri, 7 Mar 2025 15:01:45 -0500 Subject: [PATCH 081/260] add initial integration test for body-based routing extension (#458) --- pkg/body-based-routing/server/runserver.go | 28 ++-- test/integration/bbr/hermetic_test.go | 173 +++++++++++++++++++++ 2 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 test/integration/bbr/hermetic_test.go diff --git a/pkg/body-based-routing/server/runserver.go b/pkg/body-based-routing/server/runserver.go index 3674c6cf..55e79422 100644 --- a/pkg/body-based-routing/server/runserver.go +++ b/pkg/body-based-routing/server/runserver.go @@ -32,7 +32,8 @@ import ( // ExtProcServerRunner provides methods to manage an external process server. type ExtProcServerRunner struct { - GrpcPort int + GrpcPort int + SecureServing bool } // Default values for CLI flags in main @@ -42,7 +43,8 @@ const ( func NewDefaultExtProcServerRunner() *ExtProcServerRunner { return &ExtProcServerRunner{ - GrpcPort: DefaultGrpcPort, + GrpcPort: DefaultGrpcPort, + SecureServing: true, } } @@ -50,18 +52,20 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { // The runnable implements LeaderElectionRunnable with leader election disabled. func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { - cert, err := tlsutil.CreateSelfSignedTLSCertificate(logger) - if err != nil { - logger.Error(err, "Failed to create self signed certificate") - return err + var srv *grpc.Server + if r.SecureServing { + cert, err := tlsutil.CreateSelfSignedTLSCertificate(logger) + if err != nil { + logger.Error(err, "Failed to create self signed certificate") + return err + } + creds := credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}}) + srv = grpc.NewServer(grpc.Creds(creds)) + } else { + srv = grpc.NewServer() } - creds := credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}}) - srv := grpc.NewServer(grpc.Creds(creds)) - extProcPb.RegisterExternalProcessorServer( - srv, - handlers.NewServer(), - ) + extProcPb.RegisterExternalProcessorServer(srv, handlers.NewServer()) // Forward to the gRPC runnable. return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) diff --git a/test/integration/bbr/hermetic_test.go b/test/integration/bbr/hermetic_test.go new file mode 100644 index 00000000..be8b2721 --- /dev/null +++ b/test/integration/bbr/hermetic_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2025 The Kubernetes 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 bbr contains integration tests for the body-based routing extension. +package bbr + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" + "github.com/google/go-cmp/cmp" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/testing/protocmp" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/server" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +const port = runserver.DefaultGrpcPort + +var logger = logutil.NewTestLogger().V(logutil.VERBOSE) + +func TestBodyBasedRouting(t *testing.T) { + tests := []struct { + name string + req *extProcPb.ProcessingRequest + wantHeaders []*configPb.HeaderValueOption + wantErr bool + }{ + { + name: "success adding model parameter to header", + req: generateRequest(logger, "llama"), + wantHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte("llama"), + }, + }, + }, + wantErr: false, + }, + { + name: "no model parameter", + req: generateRequest(logger, ""), + wantHeaders: []*configPb.HeaderValueOption{}, + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, cleanup := setUpHermeticServer() + t.Cleanup(cleanup) + + want := &extProcPb.ProcessingResponse{} + if len(test.wantHeaders) > 0 { + want.Response = &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: test.wantHeaders, + }, + ClearRouteCache: true, + }, + }, + } + } else { + want.Response = &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{}, + } + } + + res, err := sendRequest(t, client, test.req) + if err != nil && !test.wantErr { + t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) + } + if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { + t.Errorf("Unexpected response, (-want +got): %v", diff) + } + }) + } +} + +func setUpHermeticServer() (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + serverCtx, stopServer := context.WithCancel(context.Background()) + serverRunner := runserver.NewDefaultExtProcServerRunner() + serverRunner.SecureServing = false + + go func() { + if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { + logutil.Fatal(logger, err, "Failed to start ext-proc server") + } + }() + + address := fmt.Sprintf("localhost:%v", port) + // Create a grpc connection + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + logutil.Fatal(logger, err, "Failed to connect", "address", address) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) + if err != nil { + logutil.Fatal(logger, err, "Failed to create client") + } + return client, func() { + cancel() + conn.Close() + stopServer() + + // wait a little until the goroutines actually exit + time.Sleep(5 * time.Second) + } +} + +func generateRequest(logger logr.Logger, model string) *extProcPb.ProcessingRequest { + j := map[string]interface{}{ + "prompt": "test1", + "max_tokens": 100, + "temperature": 0, + } + if model != "" { + j["model"] = model + } + + llmReq, err := json.Marshal(j) + if err != nil { + logutil.Fatal(logger, err, "Failed to unmarshal LLM request") + } + req := &extProcPb.ProcessingRequest{ + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: llmReq}, + }, + } + return req +} + +func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + + res, err := client.Recv() + if err != nil { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + return res, err +} From a70d66e5ac560246c42e503a285c804e67da40be Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 10 Mar 2025 10:57:46 -0700 Subject: [PATCH 082/260] Each pod has independent loops to refresh metrics (#460) * Each pod has independent loops to refresh metrics * Major refactoring, move metrics logic from datastore to backend/metrics package * Address comments * Fix test and fmt * The podMetrics updates the targetPort by reading the pool from the datastore --- Makefile | 2 +- cmd/epp/main.go | 10 +- pkg/epp/backend/fake.go | 48 ----- pkg/epp/backend/metrics/fake.go | 90 +++++++++ pkg/epp/backend/metrics/logger.go | 111 +++++++++++ pkg/epp/backend/metrics/pod_metrics.go | 129 ++++++++++++ pkg/epp/backend/metrics/pod_metrics_test.go | 96 +++++++++ pkg/epp/backend/metrics/types.go | 114 +++++++++++ pkg/epp/backend/provider.go | 183 ------------------ pkg/epp/backend/provider_test.go | 151 --------------- pkg/epp/backend/vllm/metrics.go | 21 +- pkg/epp/backend/vllm/metrics_test.go | 28 +-- .../inferencemodel_reconciler_test.go | 18 +- .../controller/inferencepool_reconciler.go | 2 +- .../inferencepool_reconciler_test.go | 7 +- pkg/epp/controller/pod_reconciler.go | 10 +- pkg/epp/controller/pod_reconciler_test.go | 127 ++++++------ pkg/epp/datastore/datastore.go | 133 +++++-------- pkg/epp/datastore/datastore_test.go | 132 ++++++++++++- pkg/epp/datastore/types.go | 71 ------- pkg/epp/handlers/request.go | 3 +- pkg/epp/handlers/server.go | 3 +- pkg/epp/handlers/streamingserver.go | 3 +- pkg/epp/scheduling/filter.go | 68 +++---- pkg/epp/scheduling/filter_test.go | 171 ++++++++-------- pkg/epp/scheduling/scheduler.go | 11 +- pkg/epp/server/runserver.go | 19 +- pkg/epp/test/benchmark/benchmark.go | 145 -------------- pkg/epp/test/utils.go | 126 ------------ pkg/epp/util/testing/request.go | 45 +++++ pkg/epp/util/testing/wrappers.go | 6 + test/integration/epp/hermetic_test.go | 130 +++++++------ 32 files changed, 1115 insertions(+), 1098 deletions(-) delete mode 100644 pkg/epp/backend/fake.go create mode 100644 pkg/epp/backend/metrics/fake.go create mode 100644 pkg/epp/backend/metrics/logger.go create mode 100644 pkg/epp/backend/metrics/pod_metrics.go create mode 100644 pkg/epp/backend/metrics/pod_metrics_test.go create mode 100644 pkg/epp/backend/metrics/types.go delete mode 100644 pkg/epp/backend/provider.go delete mode 100644 pkg/epp/backend/provider_test.go delete mode 100644 pkg/epp/datastore/types.go delete mode 100644 pkg/epp/test/benchmark/benchmark.go delete mode 100644 pkg/epp/test/utils.go create mode 100644 pkg/epp/util/testing/request.go diff --git a/Makefile b/Makefile index 61b17f5b..257d2cbb 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -race -coverprofile cover.out .PHONY: test-integration test-integration: manifests generate fmt vet envtest ## Run tests. diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 1f62d94a..e1cd5015 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -37,7 +37,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/vllm" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" @@ -143,22 +143,20 @@ func run() error { ctx := ctrl.SetupSignalHandler() + pmf := backendmetrics.NewPodMetricsFactory(&vllm.PodMetricsClientImpl{}, *refreshMetricsInterval) // Setup runner. - datastore := datastore.NewDatastore() - provider := backend.NewProvider(&vllm.PodMetricsClientImpl{}, datastore) + datastore := datastore.NewDatastore(ctx, pmf) serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, DestinationEndpointHintKey: *destinationEndpointHintKey, PoolName: *poolName, PoolNamespace: *poolNamespace, - RefreshMetricsInterval: *refreshMetricsInterval, - RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, Datastore: datastore, SecureServing: *secureServing, CertPath: *certPath, - Provider: provider, UseStreaming: useStreamingServer, + RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, } if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { setupLog.Error(err, "Failed to setup ext-proc controllers") diff --git a/pkg/epp/backend/fake.go b/pkg/epp/backend/fake.go deleted file mode 100644 index 584486c2..00000000 --- a/pkg/epp/backend/fake.go +++ /dev/null @@ -1,48 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 backend - -import ( - "context" - - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -type FakePodMetricsClient struct { - Err map[types.NamespacedName]error - Res map[types.NamespacedName]*datastore.PodMetrics -} - -func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, existing *datastore.PodMetrics, port int32) (*datastore.PodMetrics, error) { - if err, ok := f.Err[existing.NamespacedName]; ok { - return nil, err - } - log.FromContext(ctx).V(logutil.VERBOSE).Info("Fetching metrics for pod", "existing", existing, "new", f.Res[existing.NamespacedName]) - return f.Res[existing.NamespacedName], nil -} - -type FakeDataStore struct { - Res map[string]*v1alpha2.InferenceModel -} - -func (fds *FakeDataStore) FetchModelData(modelName string) (returnModel *v1alpha2.InferenceModel) { - return fds.Res[modelName] -} diff --git a/pkg/epp/backend/metrics/fake.go b/pkg/epp/backend/metrics/fake.go new file mode 100644 index 00000000..fae7149d --- /dev/null +++ b/pkg/epp/backend/metrics/fake.go @@ -0,0 +1,90 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "context" + "fmt" + "sync" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// FakePodMetrics is an implementation of PodMetrics that doesn't run the async refresh loop. +type FakePodMetrics struct { + Pod *Pod + Metrics *Metrics +} + +func (fpm *FakePodMetrics) GetPod() *Pod { + return fpm.Pod +} +func (fpm *FakePodMetrics) GetMetrics() *Metrics { + return fpm.Metrics +} +func (fpm *FakePodMetrics) UpdatePod(pod *corev1.Pod) { + fpm.Pod = toInternalPod(pod) +} +func (fpm *FakePodMetrics) StopRefreshLoop() {} // noop + +type FakePodMetricsClient struct { + errMu sync.RWMutex + Err map[types.NamespacedName]error + resMu sync.RWMutex + Res map[types.NamespacedName]*Metrics +} + +func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod *Pod, existing *Metrics, port int32) (*Metrics, error) { + f.errMu.RLock() + err, ok := f.Err[pod.NamespacedName] + f.errMu.RUnlock() + if ok { + return nil, err + } + f.resMu.RLock() + res, ok := f.Res[pod.NamespacedName] + f.resMu.RUnlock() + if !ok { + return nil, fmt.Errorf("no pod found: %v", pod.NamespacedName) + } + log.FromContext(ctx).V(logutil.VERBOSE).Info("Fetching metrics for pod", "existing", existing, "new", res) + return res.Clone(), nil +} + +func (f *FakePodMetricsClient) SetRes(new map[types.NamespacedName]*Metrics) { + f.resMu.Lock() + defer f.resMu.Unlock() + f.Res = new +} + +func (f *FakePodMetricsClient) SetErr(new map[types.NamespacedName]error) { + f.errMu.Lock() + defer f.errMu.Unlock() + f.Err = new +} + +type FakeDataStore struct { + Res map[string]*v1alpha2.InferenceModel +} + +func (fds *FakeDataStore) FetchModelData(modelName string) (returnModel *v1alpha2.InferenceModel) { + return fds.Res[modelName] +} diff --git a/pkg/epp/backend/metrics/logger.go b/pkg/epp/backend/metrics/logger.go new file mode 100644 index 00000000..664115eb --- /dev/null +++ b/pkg/epp/backend/metrics/logger.go @@ -0,0 +1,111 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "context" + "time" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +const ( + // Note currently the EPP treats stale metrics same as fresh. + // TODO: https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/336 + metricsValidityPeriod = 5 * time.Second +) + +type Datastore interface { + PoolGet() (*v1alpha2.InferencePool, error) + // PodMetrics operations + // PodGetAll returns all pods and metrics, including fresh and stale. + PodGetAll() []PodMetrics + PodList(func(PodMetrics) bool) []PodMetrics +} + +// StartMetricsLogger starts goroutines to 1) Print metrics debug logs if the DEBUG log level is +// enabled; 2) flushes Prometheus metrics about the backend servers. +func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometheusMetricsInterval time.Duration) { + logger := log.FromContext(ctx) + + // Periodically flush prometheus metrics for inference pool + go func() { + for { + select { + case <-ctx.Done(): + logger.V(logutil.DEFAULT).Info("Shutting down prometheus metrics thread") + return + default: + time.Sleep(refreshPrometheusMetricsInterval) + flushPrometheusMetricsOnce(logger, datastore) + } + } + }() + + // Periodically print out the pods and metrics for DEBUGGING. + if logger := logger.V(logutil.DEBUG); logger.Enabled() { + go func() { + for { + select { + case <-ctx.Done(): + logger.V(logutil.DEFAULT).Info("Shutting down metrics logger thread") + return + default: + time.Sleep(5 * time.Second) + podsWithFreshMetrics := datastore.PodList(func(pm PodMetrics) bool { + return time.Since(pm.GetMetrics().UpdateTime) <= metricsValidityPeriod + }) + podsWithStaleMetrics := datastore.PodList(func(pm PodMetrics) bool { + return time.Since(pm.GetMetrics().UpdateTime) > metricsValidityPeriod + }) + logger.Info("Current Pods and metrics gathered", "fresh metrics", podsWithFreshMetrics, "stale metrics", podsWithStaleMetrics) + } + } + }() + } +} + +func flushPrometheusMetricsOnce(logger logr.Logger, datastore Datastore) { + pool, err := datastore.PoolGet() + if err != nil { + // No inference pool or not initialize. + logger.V(logutil.VERBOSE).Info("pool is not initialized, skipping flushing metrics") + return + } + + var kvCacheTotal float64 + var queueTotal int + + podMetrics := datastore.PodGetAll() + logger.V(logutil.VERBOSE).Info("Flushing Prometheus Metrics", "ReadyPods", len(podMetrics)) + if len(podMetrics) == 0 { + return + } + + for _, pod := range podMetrics { + kvCacheTotal += pod.GetMetrics().KVCacheUsagePercent + queueTotal += pod.GetMetrics().WaitingQueueSize + } + + podTotalCount := len(podMetrics) + metrics.RecordInferencePoolAvgKVCache(pool.Name, kvCacheTotal/float64(podTotalCount)) + metrics.RecordInferencePoolAvgQueueSize(pool.Name, float64(queueTotal/podTotalCount)) +} diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go new file mode 100644 index 00000000..f76c2e8c --- /dev/null +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -0,0 +1,129 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "context" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +const ( + fetchMetricsTimeout = 5 * time.Second +) + +type podMetrics struct { + pod unsafe.Pointer // stores a *Pod + metrics unsafe.Pointer // stores a *Metrics + pmc PodMetricsClient + ds Datastore + interval time.Duration + + parentCtx context.Context + once sync.Once // ensure the StartRefreshLoop is only called once. + done chan struct{} + + logger logr.Logger +} + +type PodMetricsClient interface { + FetchMetrics(ctx context.Context, pod *Pod, existing *Metrics, port int32) (*Metrics, error) +} + +func (pm *podMetrics) GetPod() *Pod { + return (*Pod)(atomic.LoadPointer(&pm.pod)) +} + +func (pm *podMetrics) GetMetrics() *Metrics { + return (*Metrics)(atomic.LoadPointer(&pm.metrics)) +} + +func (pm *podMetrics) UpdatePod(in *corev1.Pod) { + atomic.StorePointer(&pm.pod, unsafe.Pointer(toInternalPod(in))) +} + +func toInternalPod(in *corev1.Pod) *Pod { + return &Pod{ + NamespacedName: types.NamespacedName{ + Name: in.Name, + Namespace: in.Namespace, + }, + Address: in.Status.PodIP, + } +} + +// start starts a goroutine exactly once to periodically update metrics. The goroutine will be +// stopped either when stop() is called, or the parentCtx is cancelled. +func (pm *podMetrics) startRefreshLoop() { + pm.once.Do(func() { + go func() { + pm.logger.V(logutil.DEFAULT).Info("Starting refresher", "pod", pm.GetPod()) + for { + select { + case <-pm.done: + return + case <-pm.parentCtx.Done(): + return + default: + } + + err := pm.refreshMetrics() + if err != nil { + pm.logger.V(logutil.TRACE).Error(err, "Failed to refresh metrics", "pod", pm.GetPod()) + } + + time.Sleep(pm.interval) + } + }() + }) +} + +func (pm *podMetrics) refreshMetrics() error { + pool, err := pm.ds.PoolGet() + if err != nil { + // No inference pool or not initialize. + return err + } + ctx, cancel := context.WithTimeout(context.Background(), fetchMetricsTimeout) + defer cancel() + updated, err := pm.pmc.FetchMetrics(ctx, pm.GetPod(), pm.GetMetrics(), pool.Spec.TargetPortNumber) + if err != nil { + // As refresher is running in the background, it's possible that the pod is deleted but + // the refresh goroutine doesn't read the done channel yet. In this case, we just return nil. + // The refresher will be stopped after this interval. + return nil + } + updated.UpdateTime = time.Now() + + pm.logger.V(logutil.TRACE).Info("Refreshed metrics", "updated", updated) + + atomic.StorePointer(&pm.metrics, unsafe.Pointer(updated)) + return nil +} + +func (pm *podMetrics) StopRefreshLoop() { + pm.logger.V(logutil.DEFAULT).Info("Stopping refresher", "pod", pm.GetPod()) + close(pm.done) +} diff --git a/pkg/epp/backend/metrics/pod_metrics_test.go b/pkg/epp/backend/metrics/pod_metrics_test.go new file mode 100644 index 00000000..cf6698ca --- /dev/null +++ b/pkg/epp/backend/metrics/pod_metrics_test.go @@ -0,0 +1,96 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +var ( + pod1 = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + }, + } + initial = &Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + } + updated = &Metrics{ + WaitingQueueSize: 9999, + KVCacheUsagePercent: 0.99, + MaxActiveModels: 99, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + } +) + +func TestMetricsRefresh(t *testing.T) { + ctx := context.Background() + pmc := &FakePodMetricsClient{} + pmf := NewPodMetricsFactory(pmc, time.Millisecond) + + // The refresher is initialized with empty metrics. + pm := pmf.NewPodMetrics(ctx, pod1, &fakeDataStore{}) + + namespacedName := types.NamespacedName{Name: pod1.Name, Namespace: pod1.Namespace} + // Use SetRes to simulate an update of metrics from the pod. + // Verify that the metrics are updated. + pmc.SetRes(map[types.NamespacedName]*Metrics{namespacedName: initial}) + condition := func(collect *assert.CollectT) { + assert.True(collect, cmp.Equal(pm.GetMetrics(), initial, cmpopts.IgnoreFields(Metrics{}, "UpdateTime"))) + } + assert.EventuallyWithT(t, condition, time.Second, time.Millisecond) + + // Stop the loop, and simulate metric update again, this time the PodMetrics won't get the + // new update. + pm.StopRefreshLoop() + pmc.SetRes(map[types.NamespacedName]*Metrics{namespacedName: updated}) + // Still expect the same condition (no metrics update). + assert.EventuallyWithT(t, condition, time.Second, time.Millisecond) +} + +type fakeDataStore struct{} + +func (f *fakeDataStore) PoolGet() (*v1alpha2.InferencePool, error) { + return &v1alpha2.InferencePool{Spec: v1alpha2.InferencePoolSpec{TargetPortNumber: 8000}}, nil +} +func (f *fakeDataStore) PodGetAll() []PodMetrics { + // Not implemented. + return nil +} +func (f *fakeDataStore) PodList(func(PodMetrics) bool) []PodMetrics { + // Not implemented. + return nil +} diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go new file mode 100644 index 00000000..cdbdb2ce --- /dev/null +++ b/pkg/epp/backend/metrics/types.go @@ -0,0 +1,114 @@ +/* +Copyright 2025 The Kubernetes 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 metrics is a library to interact with backend metrics. +package metrics + +import ( + "context" + "fmt" + "sync" + "time" + "unsafe" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +func NewPodMetricsFactory(pmc PodMetricsClient, refreshMetricsInterval time.Duration) *PodMetricsFactory { + return &PodMetricsFactory{ + pmc: pmc, + refreshMetricsInterval: refreshMetricsInterval, + } +} + +type PodMetricsFactory struct { + pmc PodMetricsClient + refreshMetricsInterval time.Duration +} + +func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1.Pod, ds Datastore) PodMetrics { + pm := &podMetrics{ + pod: unsafe.Pointer(toInternalPod(in)), + metrics: unsafe.Pointer(newMetrics()), + pmc: f.pmc, + ds: ds, + interval: f.refreshMetricsInterval, + parentCtx: parentCtx, + once: sync.Once{}, + done: make(chan struct{}), + logger: log.FromContext(parentCtx), + } + pm.startRefreshLoop() + return pm +} + +type PodMetrics interface { + GetPod() *Pod + GetMetrics() *Metrics + UpdatePod(*corev1.Pod) + StopRefreshLoop() +} + +type Pod struct { + NamespacedName types.NamespacedName + Address string +} + +type Metrics struct { + // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. + ActiveModels map[string]int + // MaxActiveModels is the maximum number of models that can be loaded to GPU. + MaxActiveModels int + RunningQueueSize int + WaitingQueueSize int + KVCacheUsagePercent float64 + KvCacheMaxTokenCapacity int + + // UpdateTime record the last time when the metrics were updated. + UpdateTime time.Time +} + +func newMetrics() *Metrics { + return &Metrics{ + ActiveModels: make(map[string]int), + } +} + +func (m *Metrics) String() string { + if m == nil { + return "" + } + return fmt.Sprintf("%+v", *m) +} + +func (m *Metrics) Clone() *Metrics { + cm := make(map[string]int, len(m.ActiveModels)) + for k, v := range m.ActiveModels { + cm[k] = v + } + clone := &Metrics{ + ActiveModels: cm, + MaxActiveModels: m.MaxActiveModels, + RunningQueueSize: m.RunningQueueSize, + WaitingQueueSize: m.WaitingQueueSize, + KVCacheUsagePercent: m.KVCacheUsagePercent, + KvCacheMaxTokenCapacity: m.KvCacheMaxTokenCapacity, + UpdateTime: m.UpdateTime, + } + return clone +} diff --git a/pkg/epp/backend/provider.go b/pkg/epp/backend/provider.go deleted file mode 100644 index 959f3e0c..00000000 --- a/pkg/epp/backend/provider.go +++ /dev/null @@ -1,183 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 backend - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/go-logr/logr" - "go.uber.org/multierr" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -const ( - fetchMetricsTimeout = 5 * time.Second -) - -func NewProvider(pmc PodMetricsClient, datastore datastore.Datastore) *Provider { - p := &Provider{ - pmc: pmc, - datastore: datastore, - } - return p -} - -// Provider provides backend pods and information such as metrics. -type Provider struct { - pmc PodMetricsClient - datastore datastore.Datastore -} - -type PodMetricsClient interface { - FetchMetrics(ctx context.Context, existing *datastore.PodMetrics, port int32) (*datastore.PodMetrics, error) -} - -func (p *Provider) Init(ctx context.Context, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration) error { - // periodically refresh metrics - logger := log.FromContext(ctx) - go func() { - for { - select { - case <-ctx.Done(): - logger.V(logutil.DEFAULT).Info("Shutting down metrics prober") - return - default: - time.Sleep(refreshMetricsInterval) - if err := p.refreshMetricsOnce(logger); err != nil { - logger.V(logutil.DEFAULT).Error(err, "Failed to refresh metrics") - } - } - } - }() - - // Periodically flush prometheus metrics for inference pool - go func() { - for { - select { - case <-ctx.Done(): - logger.V(logutil.DEFAULT).Info("Shutting down prometheus metrics thread") - return - default: - time.Sleep(refreshPrometheusMetricsInterval) - p.flushPrometheusMetricsOnce(logger) - } - } - }() - - // Periodically print out the pods and metrics for DEBUGGING. - if logger := logger.V(logutil.DEBUG); logger.Enabled() { - go func() { - for { - select { - case <-ctx.Done(): - logger.V(logutil.DEFAULT).Info("Shutting down metrics logger thread") - return - default: - time.Sleep(5 * time.Second) - logger.Info("Current Pods and metrics gathered", "metrics", p.datastore.PodGetAll()) - } - } - }() - } - - return nil -} - -func (p *Provider) refreshMetricsOnce(logger logr.Logger) error { - loggerTrace := logger.V(logutil.TRACE) - pool, _ := p.datastore.PoolGet() - if pool == nil { - loggerTrace.Info("No inference pool or not initialized") - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), fetchMetricsTimeout) - defer cancel() - start := time.Now() - defer func() { - d := time.Since(start) - // TODO: add a metric instead of logging - loggerTrace.Info("Metrics refreshed", "duration", d) - }() - - var wg sync.WaitGroup - errCh := make(chan error) - processOnePod := func(key, value any) bool { - loggerTrace.Info("Pod and metric being processed", "pod", key, "metric", value) - existing := value.(*datastore.PodMetrics) - wg.Add(1) - go func() { - defer wg.Done() - updated, err := p.pmc.FetchMetrics(ctx, existing, pool.Spec.TargetPortNumber) - if err != nil { - errCh <- fmt.Errorf("failed to parse metrics from %s: %v", existing.NamespacedName, err) - return - } - p.datastore.PodUpdateMetricsIfExist(updated.NamespacedName, &updated.Metrics) - loggerTrace.Info("Updated metrics for pod", "pod", updated.NamespacedName, "metrics", updated.Metrics) - }() - return true - } - p.datastore.PodRange(processOnePod) - - // Wait for metric collection for all pods to complete and close the error channel in a - // goroutine so this is unblocking, allowing the code to proceed to the error collection code - // below. - // Note we couldn't use a buffered error channel with a size because the size of the podMetrics - // sync.Map is unknown beforehand. - go func() { - wg.Wait() - close(errCh) - }() - - var errs error - for err := range errCh { - errs = multierr.Append(errs, err) - } - return errs -} - -func (p *Provider) flushPrometheusMetricsOnce(logger logr.Logger) { - pool, _ := p.datastore.PoolGet() - if pool == nil { - // No inference pool or not initialize. - return - } - - var kvCacheTotal float64 - var queueTotal int - - podMetrics := p.datastore.PodGetAll() - logger.V(logutil.VERBOSE).Info("Flushing Prometheus Metrics", "ReadyPods", len(podMetrics)) - if len(podMetrics) == 0 { - return - } - - for _, pod := range podMetrics { - kvCacheTotal += pod.KVCacheUsagePercent - queueTotal += pod.WaitingQueueSize - } - - podTotalCount := len(podMetrics) - metrics.RecordInferencePoolAvgKVCache(pool.Name, kvCacheTotal/float64(podTotalCount)) - metrics.RecordInferencePoolAvgQueueSize(pool.Name, float64(queueTotal/podTotalCount)) -} diff --git a/pkg/epp/backend/provider_test.go b/pkg/epp/backend/provider_test.go deleted file mode 100644 index 12994723..00000000 --- a/pkg/epp/backend/provider_test.go +++ /dev/null @@ -1,151 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 backend - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" -) - -var ( - pod1 = &datastore.PodMetrics{ - Pod: datastore.Pod{ - NamespacedName: types.NamespacedName{ - Name: "pod1", - }, - }, - } - pod1WithMetrics = &datastore.PodMetrics{ - Pod: pod1.Pod, - Metrics: datastore.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - }, - } - pod2 = &datastore.PodMetrics{ - Pod: datastore.Pod{ - NamespacedName: types.NamespacedName{ - Name: "pod2", - }, - }, - } - pod2WithMetrics = &datastore.PodMetrics{ - Pod: pod2.Pod, - Metrics: datastore.Metrics{ - WaitingQueueSize: 1, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo1": 1, - "bar1": 1, - }, - }, - } - - inferencePool = &v1alpha2.InferencePool{ - Spec: v1alpha2.InferencePoolSpec{ - TargetPortNumber: 8000, - }, - } -) - -func TestProvider(t *testing.T) { - tests := []struct { - name string - pmc PodMetricsClient - storePods []*datastore.PodMetrics - want []*datastore.PodMetrics - }{ - { - name: "Probing metrics success", - pmc: &FakePodMetricsClient{ - Res: map[types.NamespacedName]*datastore.PodMetrics{ - pod1.NamespacedName: pod1WithMetrics, - pod2.NamespacedName: pod2WithMetrics, - }, - }, - storePods: []*datastore.PodMetrics{pod1, pod2}, - want: []*datastore.PodMetrics{pod1WithMetrics, pod2WithMetrics}, - }, - { - name: "Only pods in the datastore are probed", - pmc: &FakePodMetricsClient{ - Res: map[types.NamespacedName]*datastore.PodMetrics{ - pod1.NamespacedName: pod1WithMetrics, - pod2.NamespacedName: pod2WithMetrics, - }, - }, - storePods: []*datastore.PodMetrics{pod1}, - want: []*datastore.PodMetrics{pod1WithMetrics}, - }, - { - name: "Probing metrics error", - pmc: &FakePodMetricsClient{ - Err: map[types.NamespacedName]error{ - pod2.NamespacedName: errors.New("injected error"), - }, - Res: map[types.NamespacedName]*datastore.PodMetrics{ - pod1.NamespacedName: pod1WithMetrics, - }, - }, - storePods: []*datastore.PodMetrics{pod1, pod2}, - want: []*datastore.PodMetrics{ - pod1WithMetrics, - // Failed to fetch pod2 metrics so it remains the default values. - { - Pod: datastore.Pod{NamespacedName: pod2.NamespacedName}, - Metrics: datastore.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0, - MaxActiveModels: 0, - }, - }, - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ds := datastore.NewFakeDatastore(test.storePods, nil, inferencePool) - p := NewProvider(test.pmc, ds) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - _ = p.Init(ctx, time.Millisecond, time.Millisecond) - assert.EventuallyWithT(t, func(t *assert.CollectT) { - metrics := ds.PodGetAll() - diff := cmp.Diff(test.want, metrics, cmpopts.SortSlices(func(a, b *datastore.PodMetrics) bool { - return a.String() < b.String() - })) - assert.Equal(t, "", diff, "Unexpected diff (+got/-want)") - }, 5*time.Second, time.Millisecond) - }) - } -} diff --git a/pkg/epp/backend/vllm/metrics.go b/pkg/epp/backend/vllm/metrics.go index 5b36b930..f83326eb 100644 --- a/pkg/epp/backend/vllm/metrics.go +++ b/pkg/epp/backend/vllm/metrics.go @@ -30,7 +30,7 @@ import ( "github.com/prometheus/common/expfmt" "go.uber.org/multierr" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -57,15 +57,16 @@ type PodMetricsClientImpl struct{} // FetchMetrics fetches metrics from a given pod. func (p *PodMetricsClientImpl) FetchMetrics( ctx context.Context, - existing *datastore.PodMetrics, + pod *metrics.Pod, + existing *metrics.Metrics, port int32, -) (*datastore.PodMetrics, error) { +) (*metrics.Metrics, error) { logger := log.FromContext(ctx) loggerDefault := logger.V(logutil.DEFAULT) // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. - url := "http://" + existing.Address + ":" + strconv.Itoa(int(port)) + "/metrics" + url := "http://" + pod.Address + ":" + strconv.Itoa(int(port)) + "/metrics" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -74,16 +75,16 @@ func (p *PodMetricsClientImpl) FetchMetrics( } resp, err := http.DefaultClient.Do(req) if err != nil { - loggerDefault.Error(err, "Failed to fetch metrics", "pod", existing.NamespacedName) - return nil, fmt.Errorf("failed to fetch metrics from %s: %w", existing.NamespacedName, err) + loggerDefault.Error(err, "Failed to fetch metrics", "pod", pod.NamespacedName) + return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod.NamespacedName, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - loggerDefault.Error(nil, "Unexpected status code returned", "pod", existing.NamespacedName, "statusCode", resp.StatusCode) - return nil, fmt.Errorf("unexpected status code from %s: %v", existing.NamespacedName, resp.StatusCode) + loggerDefault.Error(nil, "Unexpected status code returned", "pod", pod.NamespacedName, "statusCode", resp.StatusCode) + return nil, fmt.Errorf("unexpected status code from %s: %v", pod.NamespacedName, resp.StatusCode) } parser := expfmt.TextParser{} @@ -100,8 +101,8 @@ func (p *PodMetricsClientImpl) FetchMetrics( func promToPodMetrics( logger logr.Logger, metricFamilies map[string]*dto.MetricFamily, - existing *datastore.PodMetrics, -) (*datastore.PodMetrics, error) { + existing *metrics.Metrics, +) (*metrics.Metrics, error) { var errs error updated := existing.Clone() runningQueueSize, err := getLatestMetric(logger, metricFamilies, RunningQueueSizeMetricName) diff --git a/pkg/epp/backend/vllm/metrics_test.go b/pkg/epp/backend/vllm/metrics_test.go index 12aac1a1..5555bd26 100644 --- a/pkg/epp/backend/vllm/metrics_test.go +++ b/pkg/epp/backend/vllm/metrics_test.go @@ -23,7 +23,7 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/proto" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -31,11 +31,11 @@ func TestPromToPodMetrics(t *testing.T) { logger := logutil.NewTestLogger() testCases := []struct { - name string - metricFamilies map[string]*dto.MetricFamily - expectedMetrics *datastore.Metrics - expectedErr error - initialPodMetrics *datastore.PodMetrics + name string + metricFamilies map[string]*dto.MetricFamily + initialMetrics *metrics.Metrics + expectedMetrics *metrics.Metrics + expectedErr error }{ { name: "all metrics available", @@ -123,7 +123,7 @@ func TestPromToPodMetrics(t *testing.T) { }, }, }, - expectedMetrics: &datastore.Metrics{ + expectedMetrics: &metrics.Metrics{ RunningQueueSize: 15, WaitingQueueSize: 25, KVCacheUsagePercent: 0.9, @@ -133,8 +133,8 @@ func TestPromToPodMetrics(t *testing.T) { }, MaxActiveModels: 2, }, - initialPodMetrics: &datastore.PodMetrics{}, - expectedErr: nil, + initialMetrics: &metrics.Metrics{}, + expectedErr: nil, }, { name: "invalid max lora", @@ -222,7 +222,7 @@ func TestPromToPodMetrics(t *testing.T) { }, }, }, - expectedMetrics: &datastore.Metrics{ + expectedMetrics: &metrics.Metrics{ RunningQueueSize: 15, WaitingQueueSize: 25, KVCacheUsagePercent: 0.9, @@ -232,18 +232,18 @@ func TestPromToPodMetrics(t *testing.T) { }, MaxActiveModels: 0, }, - initialPodMetrics: &datastore.PodMetrics{}, - expectedErr: errors.New("strconv.Atoi: parsing '2a': invalid syntax"), + initialMetrics: &metrics.Metrics{}, + expectedErr: errors.New("strconv.Atoi: parsing '2a': invalid syntax"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - updated, err := promToPodMetrics(logger, tc.metricFamilies, tc.initialPodMetrics) + updated, err := promToPodMetrics(logger, tc.metricFamilies, tc.initialMetrics) if tc.expectedErr != nil { assert.Error(t, err) } else { assert.NoError(t, err) - assert.Equal(t, tc.expectedMetrics, &updated.Metrics) + assert.Equal(t, tc.expectedMetrics, updated) } }) } diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index 2ac5bb1e..cd1ff1fb 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -19,6 +19,7 @@ package controller import ( "context" "testing" + "time" "github.com/google/go-cmp/cmp" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" utiltest "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) @@ -189,12 +191,16 @@ func TestInferenceModelReconciler(t *testing.T) { WithObjects(initObjs...). WithIndex(&v1alpha2.InferenceModel{}, datastore.ModelNameIndexKey, indexInferenceModelsByModelName). Build() - - datastore := datastore.NewFakeDatastore(nil, test.modelsInStore, pool) + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) + ds := datastore.NewDatastore(t.Context(), pmf) + for _, m := range test.modelsInStore { + ds.ModelSetIfOlder(m) + } + ds.PoolSet(pool) reconciler := &InferenceModelReconciler{ Client: fakeClient, Record: record.NewFakeRecorder(10), - Datastore: datastore, + Datastore: ds, PoolNamespacedName: types.NamespacedName{Name: pool.Name, Namespace: pool.Namespace}, } if test.incomingReq == nil { @@ -211,11 +217,11 @@ func TestInferenceModelReconciler(t *testing.T) { t.Errorf("Unexpected result diff (+got/-want): %s", diff) } - if len(test.wantModels) != len(datastore.ModelGetAll()) { - t.Errorf("Unexpected; want: %d, got:%d", len(test.wantModels), len(datastore.ModelGetAll())) + if len(test.wantModels) != len(ds.ModelGetAll()) { + t.Errorf("Unexpected; want: %d, got:%d", len(test.wantModels), len(ds.ModelGetAll())) } - if diff := diffStore(datastore, diffStoreParams{wantPool: pool, wantModels: test.wantModels}); diff != "" { + if diff := diffStore(ds, diffStoreParams{wantPool: pool, wantModels: test.wantModels}); diff != "" { t.Errorf("Unexpected diff (+got/-want): %s", diff) } diff --git a/pkg/epp/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go index 880aec8c..c92d4ecc 100644 --- a/pkg/epp/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -80,7 +80,7 @@ func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool * // 2) If the selector on the pool was updated, then we will not get any pod events, and so we need // to resync the whole pool: remove pods in the store that don't match the new selector and add // the ones that may have existed already to the store. - c.Datastore.PodResyncAll(ctx, c.Client) + c.Datastore.PodResyncAll(ctx, c.Client, newPool) } } diff --git a/pkg/epp/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go index f35b8dc0..27c4238e 100644 --- a/pkg/epp/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -19,6 +19,7 @@ package controller import ( "context" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -30,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" utiltest "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) @@ -92,7 +94,8 @@ func TestInferencePoolReconciler(t *testing.T) { req := ctrl.Request{NamespacedName: namespacedName} ctx := context.Background() - datastore := datastore.NewDatastore() + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) + datastore := datastore.NewDatastore(ctx, pmf) inferencePoolReconciler := &InferencePoolReconciler{PoolNamespacedName: namespacedName, Client: fakeClient, Datastore: datastore} // Step 1: Inception, only ready pods matching pool1 are added to the store. @@ -167,7 +170,7 @@ func diffStore(datastore datastore.Datastore, params diffStoreParams) string { } gotPods := []string{} for _, pm := range datastore.PodGetAll() { - gotPods = append(gotPods, pm.NamespacedName.Name) + gotPods = append(gotPods, pm.GetPod().NamespacedName.Name) } if diff := cmp.Diff(params.wantPods, gotPods, cmpopts.SortSlices(func(a, b string) bool { return a < b })); diff != "" { return "pods:" + diff diff --git a/pkg/epp/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go index a6c897c2..046561e4 100644 --- a/pkg/epp/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -27,6 +27,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -39,7 +40,8 @@ type PodReconciler struct { func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) - if !c.Datastore.PoolHasSynced() { + pool, err := c.Datastore.PoolGet() + if err != nil { logger.V(logutil.TRACE).Info("Skipping reconciling Pod because the InferencePool is not available yet") // When the inferencePool is initialized it lists the appropriate pods and populates the datastore, so no need to requeue. return ctrl.Result{}, nil @@ -57,7 +59,7 @@ func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R return ctrl.Result{}, err } - c.updateDatastore(logger, pod) + c.updateDatastore(logger, pod, pool) return ctrl.Result{}, nil } @@ -67,13 +69,13 @@ func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(c) } -func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod) { +func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod, pool *v1alpha2.InferencePool) { namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} if !pod.DeletionTimestamp.IsZero() || !c.Datastore.PoolLabelsMatch(pod.Labels) || !podIsReady(pod) { logger.V(logutil.DEBUG).Info("Pod removed or not added", "name", namespacedName) c.Datastore.PodDelete(namespacedName) } else { - if c.Datastore.PodUpdateOrAddIfNotExist(pod) { + if c.Datastore.PodUpdateOrAddIfNotExist(pod, pool) { logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName) } else { logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName) diff --git a/pkg/epp/controller/pod_reconciler_test.go b/pkg/epp/controller/pod_reconciler_test.go index 7534ac0f..e4cb0b62 100644 --- a/pkg/epp/controller/pod_reconciler_test.go +++ b/pkg/epp/controller/pod_reconciler_test.go @@ -19,10 +19,12 @@ package controller import ( "context" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -30,129 +32,138 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" utiltest "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) var ( - basePod1 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-1"}} - basePod2 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}, Address: "address-2"}} - basePod3 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}, Address: "address-3"}} - basePod11 = &datastore.PodMetrics{Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}, Address: "address-11"}} + basePod1 = &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, Status: corev1.PodStatus{PodIP: "address-1"}} + basePod2 = &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod2"}, Status: corev1.PodStatus{PodIP: "address-2"}} + basePod3 = &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod3"}, Status: corev1.PodStatus{PodIP: "address-3"}} + basePod11 = &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, Status: corev1.PodStatus{PodIP: "address-11"}} + pmc = &backendmetrics.FakePodMetricsClient{} + pmf = backendmetrics.NewPodMetricsFactory(pmc, time.Second) ) func TestPodReconciler(t *testing.T) { tests := []struct { - name string - datastore datastore.Datastore - incomingPod *corev1.Pod - wantPods []datastore.Pod - req *ctrl.Request + name string + pool *v1alpha2.InferencePool + existingPods []*corev1.Pod + incomingPod *corev1.Pod + wantPods []*corev1.Pod + req *ctrl.Request }{ { - name: "Add new pod", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "Add new pod", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), - incomingPod: utiltest.MakePod(basePod3.NamespacedName.Name). + }, + incomingPod: utiltest.FromBase(basePod3). Labels(map[string]string{"some-key": "some-val"}). - IP(basePod3.Address). ReadyCondition().ObjRef(), - wantPods: []datastore.Pod{basePod1.Pod, basePod2.Pod, basePod3.Pod}, + wantPods: []*corev1.Pod{basePod1, basePod2, basePod3}, }, { - name: "Update pod1 address", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "Update pod1 address", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), - incomingPod: utiltest.MakePod(basePod11.NamespacedName.Name). + }, + incomingPod: utiltest.FromBase(basePod11). Labels(map[string]string{"some-key": "some-val"}). - IP(basePod11.Address). ReadyCondition().ObjRef(), - wantPods: []datastore.Pod{basePod11.Pod, basePod2.Pod}, + wantPods: []*corev1.Pod{basePod11, basePod2}, }, { - name: "Delete pod with DeletionTimestamp", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "Delete pod with DeletionTimestamp", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), - incomingPod: utiltest.MakePod("pod1"). + }, + incomingPod: utiltest.FromBase(basePod1). Labels(map[string]string{"some-key": "some-val"}). DeletionTimestamp(). ReadyCondition().ObjRef(), - wantPods: []datastore.Pod{basePod2.Pod}, + wantPods: []*corev1.Pod{basePod2}, }, { - name: "Delete notfound pod", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "Delete notfound pod", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), + }, req: &ctrl.Request{NamespacedName: types.NamespacedName{Name: "pod1"}}, - wantPods: []datastore.Pod{basePod2.Pod}, + wantPods: []*corev1.Pod{basePod2}, }, { - name: "New pod, not ready, valid selector", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "New pod, not ready, valid selector", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), - incomingPod: utiltest.MakePod("pod3"). + }, + incomingPod: utiltest.FromBase(basePod3). Labels(map[string]string{"some-key": "some-val"}).ObjRef(), - wantPods: []datastore.Pod{basePod1.Pod, basePod2.Pod}, + wantPods: []*corev1.Pod{basePod1, basePod2}, }, { - name: "Remove pod that does not match selector", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "Remove pod that does not match selector", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), - incomingPod: utiltest.MakePod("pod1"). + }, + incomingPod: utiltest.FromBase(basePod1). Labels(map[string]string{"some-wrong-key": "some-val"}). ReadyCondition().ObjRef(), - wantPods: []datastore.Pod{basePod2.Pod}, + wantPods: []*corev1.Pod{basePod2}, }, { - name: "Remove pod that is not ready", - datastore: datastore.NewFakeDatastore([]*datastore.PodMetrics{basePod1, basePod2}, nil, &v1alpha2.InferencePool{ + name: "Remove pod that is not ready", + existingPods: []*corev1.Pod{basePod1, basePod2}, + pool: &v1alpha2.InferencePool{ Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ "some-key": "some-val", }, }, - }), - incomingPod: utiltest.MakePod("pod1"). + }, + incomingPod: utiltest.FromBase(basePod1). Labels(map[string]string{"some-wrong-key": "some-val"}). ReadyCondition().ObjRef(), - wantPods: []datastore.Pod{basePod2.Pod}, + wantPods: []*corev1.Pod{basePod2}, }, } for _, test := range tests { @@ -169,24 +180,28 @@ func TestPodReconciler(t *testing.T) { WithObjects(initialObjects...). Build() - podReconciler := &PodReconciler{Client: fakeClient, Datastore: test.datastore} - namespacedName := types.NamespacedName{Name: test.incomingPod.Name, Namespace: test.incomingPod.Namespace} + // Configure the initial state of the datastore. + store := datastore.NewDatastore(t.Context(), pmf) + store.PoolSet(test.pool) + for _, pod := range test.existingPods { + store.PodUpdateOrAddIfNotExist(pod, pool) + } + + podReconciler := &PodReconciler{Client: fakeClient, Datastore: store} if test.req == nil { + namespacedName := types.NamespacedName{Name: test.incomingPod.Name, Namespace: test.incomingPod.Namespace} test.req = &ctrl.Request{NamespacedName: namespacedName} } if _, err := podReconciler.Reconcile(context.Background(), *test.req); err != nil { t.Errorf("Unexpected InferencePool reconcile error: %v", err) } - var gotPods []datastore.Pod - test.datastore.PodRange(func(k, v any) bool { - pod := v.(*datastore.PodMetrics) - if v != nil { - gotPods = append(gotPods, pod.Pod) - } - return true - }) - if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b datastore.Pod) bool { return a.NamespacedName.String() < b.NamespacedName.String() })) { + var gotPods []*corev1.Pod + for _, pm := range store.PodGetAll() { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: pm.GetPod().NamespacedName.Name, Namespace: pm.GetPod().NamespacedName.Namespace}, Status: corev1.PodStatus{PodIP: pm.GetPod().Address}} + gotPods = append(gotPods, pod) + } + if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b *corev1.Pod) bool { return a.Name < b.Name })) { t.Errorf("got (%v) != want (%v);", gotPods, test.wantPods) } }) diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index c7050437..af31da42 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -57,56 +58,40 @@ type Datastore interface { ModelGetAll() []*v1alpha2.InferenceModel // PodMetrics operations - PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool - PodUpdateMetricsIfExist(namespacedName types.NamespacedName, m *Metrics) bool - PodGet(namespacedName types.NamespacedName) *PodMetrics + // PodGetAll returns all pods and metrics, including fresh and stale. + PodGetAll() []backendmetrics.PodMetrics + // PodList lists pods matching the given predicate. + PodList(func(backendmetrics.PodMetrics) bool) []backendmetrics.PodMetrics + PodUpdateOrAddIfNotExist(pod *corev1.Pod, pool *v1alpha2.InferencePool) bool PodDelete(namespacedName types.NamespacedName) - PodResyncAll(ctx context.Context, ctrlClient client.Client) - PodGetAll() []*PodMetrics - PodDeleteAll() // This is only for testing. - PodRange(f func(key, value any) bool) + PodResyncAll(ctx context.Context, ctrlClient client.Client, pool *v1alpha2.InferencePool) // Clears the store state, happens when the pool gets deleted. Clear() } -func NewDatastore() Datastore { +func NewDatastore(parentCtx context.Context, pmf *backendmetrics.PodMetricsFactory) *datastore { store := &datastore{ + parentCtx: parentCtx, poolAndModelsMu: sync.RWMutex{}, models: make(map[string]*v1alpha2.InferenceModel), pods: &sync.Map{}, - } - return store -} - -// Used for test only -func NewFakeDatastore(pods []*PodMetrics, models []*v1alpha2.InferenceModel, pool *v1alpha2.InferencePool) Datastore { - store := NewDatastore() - - for _, pod := range pods { - // Making a copy since in tests we may use the same global PodMetric across tests. - p := *pod - store.(*datastore).pods.Store(pod.NamespacedName, &p) - } - - for _, m := range models { - store.ModelSetIfOlder(m) - } - - if pool != nil { - store.(*datastore).pool = pool + pmf: pmf, } return store } type datastore struct { + // parentCtx controls the lifecycle of the background metrics goroutines that spawn up by the datastore. + parentCtx context.Context // poolAndModelsMu is used to synchronize access to pool and the models map. poolAndModelsMu sync.RWMutex pool *v1alpha2.InferencePool // key: InferenceModel.Spec.ModelName, value: *InferenceModel models map[string]*v1alpha2.InferenceModel - // key: types.NamespacedName, value: *PodMetrics + // key: types.NamespacedName, value: backendmetrics.PodMetrics pods *sync.Map + pmf *backendmetrics.PodMetricsFactory } func (ds *datastore) Clear() { @@ -227,68 +212,44 @@ func (ds *datastore) ModelGetAll() []*v1alpha2.InferenceModel { } // /// Pods/endpoints APIs /// -func (ds *datastore) PodUpdateMetricsIfExist(namespacedName types.NamespacedName, m *Metrics) bool { - if val, ok := ds.pods.Load(namespacedName); ok { - existing := val.(*PodMetrics) - existing.Metrics = *m - return true - } - return false -} -func (ds *datastore) PodGet(namespacedName types.NamespacedName) *PodMetrics { - val, ok := ds.pods.Load(namespacedName) - if ok { - return val.(*PodMetrics) - } - return nil +func (ds *datastore) PodGetAll() []backendmetrics.PodMetrics { + return ds.PodList(func(backendmetrics.PodMetrics) bool { return true }) } -func (ds *datastore) PodGetAll() []*PodMetrics { - res := []*PodMetrics{} +func (ds *datastore) PodList(predicate func(backendmetrics.PodMetrics) bool) []backendmetrics.PodMetrics { + res := []backendmetrics.PodMetrics{} fn := func(k, v any) bool { - res = append(res, v.(*PodMetrics)) + pm := v.(backendmetrics.PodMetrics) + if predicate(pm) { + res = append(res, pm) + } return true } ds.pods.Range(fn) return res } -func (ds *datastore) PodRange(f func(key, value any) bool) { - ds.pods.Range(f) -} - -func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { - ds.pods.Delete(namespacedName) -} - -func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool { - new := &PodMetrics{ - Pod: Pod{ - NamespacedName: types.NamespacedName{ - Name: pod.Name, - Namespace: pod.Namespace, - }, - Address: pod.Status.PodIP, - }, - Metrics: Metrics{ - ActiveModels: make(map[string]int), - }, +func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod, pool *v1alpha2.InferencePool) bool { + namespacedName := types.NamespacedName{ + Name: pod.Name, + Namespace: pod.Namespace, } - existing, ok := ds.pods.Load(new.NamespacedName) + var pm backendmetrics.PodMetrics + existing, ok := ds.pods.Load(namespacedName) if !ok { - ds.pods.Store(new.NamespacedName, new) - return true + pm = ds.pmf.NewPodMetrics(ds.parentCtx, pod, ds) + ds.pods.Store(namespacedName, pm) + } else { + pm = existing.(backendmetrics.PodMetrics) } - // Update pod properties if anything changed. - existing.(*PodMetrics).Pod = new.Pod - return false + pm.UpdatePod(pod) + return ok } -func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client) { - // Pool must exist to invoke this function. - pool, _ := ds.PoolGet() +func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client, pool *v1alpha2.InferencePool) { + logger := log.FromContext(ctx) podList := &corev1.PodList{} if err := ctrlClient.List(ctx, podList, &client.ListOptions{ LabelSelector: selectorFromInferencePoolSelector(pool.Spec.Selector), @@ -301,24 +262,34 @@ func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client) activePods := make(map[string]bool) for _, pod := range podList.Items { if podIsReady(&pod) { + namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} activePods[pod.Name] = true - ds.PodUpdateOrAddIfNotExist(&pod) + if ds.PodUpdateOrAddIfNotExist(&pod, pool) { + logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName) + } else { + logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName) + } } } // Remove pods that don't belong to the pool or not ready any more. deleteFn := func(k, v any) bool { - pm := v.(*PodMetrics) - if exist := activePods[pm.NamespacedName.Name]; !exist { - ds.pods.Delete(pm.NamespacedName) + pm := v.(backendmetrics.PodMetrics) + if exist := activePods[pm.GetPod().NamespacedName.Name]; !exist { + logger.V(logutil.VERBOSE).Info("Removing pod", "pod", pm.GetPod()) + ds.PodDelete(pm.GetPod().NamespacedName) } return true } ds.pods.Range(deleteFn) } -func (ds *datastore) PodDeleteAll() { - ds.pods.Clear() +func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { + v, ok := ds.pods.LoadAndDelete(namespacedName) + if ok { + pmr := v.(backendmetrics.PodMetrics) + pmr.StopRefreshLoop() + } } func selectorFromInferencePoolSelector(selector map[v1alpha2.LabelKey]v1alpha2.LabelValue) labels.Selector { diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index 8fb269bc..f60a4cc9 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -17,13 +17,19 @@ limitations under the License. package datastore import ( + "context" + "errors" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" testutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) @@ -66,7 +72,8 @@ func TestPool(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - datastore := NewDatastore() + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) + datastore := NewDatastore(context.Background(), pmf) datastore.PoolSet(tt.inferencePool) gotPool, gotErr := datastore.PoolGet() if diff := cmp.Diff(tt.wantErr, gotErr, cmpopts.EquateErrors()); diff != "" { @@ -197,7 +204,12 @@ func TestModel(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ds := NewFakeDatastore(nil, test.existingModels, nil) + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) + ds := NewDatastore(t.Context(), pmf) + for _, m := range test.existingModels { + ds.ModelSetIfOlder(m) + } + gotOpResult := test.op(ds) if gotOpResult != test.wantOpResult { t.Errorf("Unexpected operation result, want: %v, got: %v", test.wantOpResult, gotOpResult) @@ -317,3 +329,119 @@ func TestRandomWeightedDraw(t *testing.T) { func pointer(v int32) *int32 { return &v } + +var ( + pod1 = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + }, + } + pod1Metrics = &backendmetrics.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + } + pod2 = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod2", + }, + } + pod2Metrics = &backendmetrics.Metrics{ + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo1": 1, + "bar1": 1, + }, + } + pod1NamespacedName = types.NamespacedName{Name: pod1.Name, Namespace: pod1.Namespace} + pod2NamespacedName = types.NamespacedName{Name: pod2.Name, Namespace: pod2.Namespace} + inferencePool = &v1alpha2.InferencePool{ + Spec: v1alpha2.InferencePoolSpec{ + TargetPortNumber: 8000, + }, + } +) + +func TestMetrics(t *testing.T) { + tests := []struct { + name string + pmc backendmetrics.PodMetricsClient + storePods []*corev1.Pod + want []*backendmetrics.Metrics + }{ + { + name: "Probing metrics success", + pmc: &backendmetrics.FakePodMetricsClient{ + Res: map[types.NamespacedName]*backendmetrics.Metrics{ + pod1NamespacedName: pod1Metrics, + pod2NamespacedName: pod2Metrics, + }, + }, + storePods: []*corev1.Pod{pod1, pod2}, + want: []*backendmetrics.Metrics{pod1Metrics, pod2Metrics}, + }, + { + name: "Only pods in are probed", + pmc: &backendmetrics.FakePodMetricsClient{ + Res: map[types.NamespacedName]*backendmetrics.Metrics{ + pod1NamespacedName: pod1Metrics, + pod2NamespacedName: pod2Metrics, + }, + }, + storePods: []*corev1.Pod{pod1}, + want: []*backendmetrics.Metrics{pod1Metrics}, + }, + { + name: "Probing metrics error", + pmc: &backendmetrics.FakePodMetricsClient{ + Err: map[types.NamespacedName]error{ + pod2NamespacedName: errors.New("injected error"), + }, + Res: map[types.NamespacedName]*backendmetrics.Metrics{ + pod1NamespacedName: pod1Metrics, + }, + }, + storePods: []*corev1.Pod{pod1, pod2}, + want: []*backendmetrics.Metrics{ + pod1Metrics, + // Failed to fetch pod2 metrics so it remains the default values. + { + ActiveModels: map[string]int{}, + WaitingQueueSize: 0, + KVCacheUsagePercent: 0, + MaxActiveModels: 0, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + pmf := backendmetrics.NewPodMetricsFactory(test.pmc, time.Millisecond) + ds := NewDatastore(ctx, pmf) + ds.PoolSet(inferencePool) + for _, pod := range test.storePods { + ds.PodUpdateOrAddIfNotExist(pod, inferencePool) + } + assert.EventuallyWithT(t, func(t *assert.CollectT) { + got := ds.PodGetAll() + metrics := []*backendmetrics.Metrics{} + for _, one := range got { + metrics = append(metrics, one.GetMetrics()) + } + diff := cmp.Diff(test.want, metrics, cmpopts.IgnoreFields(backendmetrics.Metrics{}, "UpdateTime"), cmpopts.SortSlices(func(a, b *backendmetrics.Metrics) bool { + return a.String() < b.String() + })) + assert.Equal(t, "", diff, "Unexpected diff (+got/-want)") + }, 5*time.Second, time.Millisecond) + }) + } +} diff --git a/pkg/epp/datastore/types.go b/pkg/epp/datastore/types.go deleted file mode 100644 index 8cfcf1d1..00000000 --- a/pkg/epp/datastore/types.go +++ /dev/null @@ -1,71 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 datastore is a library to interact with backend model servers such as probing metrics. -package datastore - -import ( - "fmt" - - "k8s.io/apimachinery/pkg/types" -) - -type Pod struct { - NamespacedName types.NamespacedName - Address string -} - -type Metrics struct { - // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. - ActiveModels map[string]int - // MaxActiveModels is the maximum number of models that can be loaded to GPU. - MaxActiveModels int - RunningQueueSize int - WaitingQueueSize int - KVCacheUsagePercent float64 - KvCacheMaxTokenCapacity int -} - -type PodMetrics struct { - Pod - Metrics -} - -func (pm *PodMetrics) String() string { - return fmt.Sprintf("Pod: %+v; Address: %+v; Metrics: %+v", pm.NamespacedName, pm.Address, pm.Metrics) -} - -func (pm *PodMetrics) Clone() *PodMetrics { - cm := make(map[string]int, len(pm.ActiveModels)) - for k, v := range pm.ActiveModels { - cm[k] = v - } - clone := &PodMetrics{ - Pod: Pod{ - NamespacedName: pm.NamespacedName, - Address: pm.Address, - }, - Metrics: Metrics{ - ActiveModels: cm, - MaxActiveModels: pm.MaxActiveModels, - RunningQueueSize: pm.RunningQueueSize, - WaitingQueueSize: pm.WaitingQueueSize, - KVCacheUsagePercent: pm.KVCacheUsagePercent, - KvCacheMaxTokenCapacity: pm.KvCacheMaxTokenCapacity, - }, - } - return clone -} diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index 20271913..12afe4d7 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -94,10 +94,11 @@ func (s *Server) HandleRequestBody( loggerVerbose.Info("Updated request body marshalled", "body", string(requestBody)) } - targetPod, err := s.scheduler.Schedule(ctx, llmReq) + target, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} } + targetPod := target.GetPod() logger.V(logutil.DEFAULT).Info("Request handled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index bbdbe83e..be882fc7 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -26,6 +26,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" @@ -56,7 +57,7 @@ type Server struct { } type Scheduler interface { - Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod datastore.PodMetrics, err error) + Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod backendmetrics.PodMetrics, err error) } func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 821dd989..c8de7bb7 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -347,10 +347,11 @@ func (s *StreamingServer) HandleRequestBody( loggerVerbose.Info("Updated request body marshalled", "body", string(requestBodyBytes)) } - targetPod, err := s.scheduler.Schedule(ctx, llmReq) + target, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { return reqCtx, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} } + targetPod := target.GetPod() // Insert target endpoint to instruct Envoy to route requests to the specified target pod. // Attach the port number diff --git a/pkg/epp/scheduling/filter.go b/pkg/epp/scheduling/filter.go index d3c22673..cee683c5 100644 --- a/pkg/epp/scheduling/filter.go +++ b/pkg/epp/scheduling/filter.go @@ -23,13 +23,13 @@ import ( "time" "github.com/go-logr/logr" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type Filter interface { Name() string - Filter(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) + Filter(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) } // filter applies current filterFunc, and then recursively applies next filters depending success or @@ -59,7 +59,7 @@ func (f *filter) Name() string { return f.name } -func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { +func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { loggerTrace := logger.V(logutil.TRACE) loggerTrace.Info("Running a filter", "name", f.Name(), "podCount", len(pods)) @@ -92,12 +92,12 @@ func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []*datastore.P } // filterFunc filters a set of input pods to a subset. -type filterFunc func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) +type filterFunc func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) // toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. func toFilterFunc(pp podPredicate) filterFunc { - return func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { - filtered := []*datastore.PodMetrics{} + return func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { + filtered := []backendmetrics.PodMetrics{} for _, pod := range pods { pass := pp(req, pod) if pass { @@ -118,30 +118,30 @@ func toFilterFunc(pp podPredicate) filterFunc { // the least one as it gives more choices for the next filter, which on aggregate gave better // results. // TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { +func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { min := math.MaxInt max := 0 - filtered := []*datastore.PodMetrics{} + filtered := []backendmetrics.PodMetrics{} for _, pod := range pods { - if pod.WaitingQueueSize <= min { - min = pod.WaitingQueueSize + if pod.GetMetrics().WaitingQueueSize <= min { + min = pod.GetMetrics().WaitingQueueSize } - if pod.WaitingQueueSize >= max { - max = pod.WaitingQueueSize + if pod.GetMetrics().WaitingQueueSize >= max { + max = pod.GetMetrics().WaitingQueueSize } } for _, pod := range pods { - if pod.WaitingQueueSize >= min && pod.WaitingQueueSize <= min+(max-min)/len(pods) { + if pod.GetMetrics().WaitingQueueSize >= min && pod.GetMetrics().WaitingQueueSize <= min+(max-min)/len(pods) { filtered = append(filtered, pod) } } return filtered, nil } -func lowQueueingPodPredicate(_ *LLMRequest, pod *datastore.PodMetrics) bool { - return pod.WaitingQueueSize < queueingThresholdLoRA +func lowQueueingPodPredicate(_ *LLMRequest, pod backendmetrics.PodMetrics) bool { + return pod.GetMetrics().WaitingQueueSize < queueingThresholdLoRA } // leastKVCacheFilterFunc finds the max and min KV cache of all pods, divides the whole range @@ -150,22 +150,22 @@ func lowQueueingPodPredicate(_ *LLMRequest, pod *datastore.PodMetrics) bool { // should consider them all instead of the absolute minimum one. This worked better than picking the // least one as it gives more choices for the next filter, which on aggregate gave better results. // TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { +func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { min := math.MaxFloat64 var max float64 = 0 - filtered := []*datastore.PodMetrics{} + filtered := []backendmetrics.PodMetrics{} for _, pod := range pods { - if pod.KVCacheUsagePercent <= min { - min = pod.KVCacheUsagePercent + if pod.GetMetrics().KVCacheUsagePercent <= min { + min = pod.GetMetrics().KVCacheUsagePercent } - if pod.KVCacheUsagePercent >= max { - max = pod.KVCacheUsagePercent + if pod.GetMetrics().KVCacheUsagePercent >= max { + max = pod.GetMetrics().KVCacheUsagePercent } } for _, pod := range pods { - if pod.KVCacheUsagePercent >= min && pod.KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { + if pod.GetMetrics().KVCacheUsagePercent >= min && pod.GetMetrics().KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { filtered = append(filtered, pod) } } @@ -173,16 +173,16 @@ func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []*datasto } // podPredicate is a filter function to check whether a pod is desired. -type podPredicate func(req *LLMRequest, pod *datastore.PodMetrics) bool +type podPredicate func(req *LLMRequest, pod backendmetrics.PodMetrics) bool // We consider serving an adapter low cost it the adapter is active in the model server, or the // model server has room to load the adapter. The lowLoRACostPredicate ensures weak affinity by // spreading the load of a LoRA adapter across multiple pods, avoiding "pinning" all requests to // a single pod. This gave good performance in our initial benchmarking results in the scenario // where # of lora slots > # of lora adapters. -func lowLoRACostPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { - _, ok := pod.ActiveModels[req.ResolvedTargetModel] - return ok || len(pod.ActiveModels) < pod.MaxActiveModels +func lowLoRACostPredicate(req *LLMRequest, pod backendmetrics.PodMetrics) bool { + _, ok := pod.GetMetrics().ActiveModels[req.ResolvedTargetModel] + return ok || len(pod.GetMetrics().ActiveModels) < pod.GetMetrics().MaxActiveModels } // loRASoftAffinityPredicate implements a pod selection strategy that prioritizes pods @@ -201,18 +201,18 @@ func lowLoRACostPredicate(req *LLMRequest, pod *datastore.PodMetrics) bool { // Returns: // - Filtered slice of pod metrics based on affinity and availability // - Error if any issues occur during filtering -func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { +func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { // Pre-allocate slices with estimated capacity - filtered_affinity := make([]*datastore.PodMetrics, 0, len(pods)) - filtered_available := make([]*datastore.PodMetrics, 0, len(pods)) + filtered_affinity := make([]backendmetrics.PodMetrics, 0, len(pods)) + filtered_available := make([]backendmetrics.PodMetrics, 0, len(pods)) // Categorize pods based on affinity and availability for _, pod := range pods { - if _, exists := pod.ActiveModels[req.ResolvedTargetModel]; exists { + if _, exists := pod.GetMetrics().ActiveModels[req.ResolvedTargetModel]; exists { filtered_affinity = append(filtered_affinity, pod) - } else if len(pod.ActiveModels) < pod.MaxActiveModels { + } else if len(pod.GetMetrics().ActiveModels) < pod.GetMetrics().MaxActiveModels { filtered_available = append(filtered_available, pod) } } @@ -237,12 +237,12 @@ func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []*datasto return filtered_available, nil } -func criticalRequestPredicate(req *LLMRequest, _ *datastore.PodMetrics) bool { +func criticalRequestPredicate(req *LLMRequest, _ backendmetrics.PodMetrics) bool { return req.Critical } func noQueueAndLessThanKVCacheThresholdPredicate(queueThreshold int, kvCacheThreshold float64) podPredicate { - return func(req *LLMRequest, pod *datastore.PodMetrics) bool { - return pod.WaitingQueueSize <= queueThreshold && pod.KVCacheUsagePercent <= kvCacheThreshold + return func(req *LLMRequest, pod backendmetrics.PodMetrics) bool { + return pod.GetMetrics().WaitingQueueSize <= queueThreshold && pod.GetMetrics().KVCacheUsagePercent <= kvCacheThreshold } } diff --git a/pkg/epp/scheduling/filter_test.go b/pkg/epp/scheduling/filter_test.go index f76cece9..62ffe7f2 100644 --- a/pkg/epp/scheduling/filter_test.go +++ b/pkg/epp/scheduling/filter_test.go @@ -23,7 +23,7 @@ import ( "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -33,14 +33,14 @@ func TestFilter(t *testing.T) { tests := []struct { name string req *LLMRequest - input []*datastore.PodMetrics - output []*datastore.PodMetrics + input []*backendmetrics.FakePodMetrics + output []*backendmetrics.FakePodMetrics err bool filter *filter }{ { name: "simple filter without successor, failure", - filter: &filter{filter: func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { + filter: &filter{filter: func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { return nil, errors.New("filter error") }}, err: true, @@ -55,10 +55,10 @@ func TestFilter(t *testing.T) { }, // pod2 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -69,8 +69,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -81,8 +81,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -92,10 +92,10 @@ func TestFilter(t *testing.T) { }, }, }, - output: []*datastore.PodMetrics{ + output: []*backendmetrics.FakePodMetrics{ { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -116,10 +116,10 @@ func TestFilter(t *testing.T) { Critical: false, }, // pod1 will be picked because it has capacity for the sheddable request. - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -130,8 +130,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -142,8 +142,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -153,10 +153,10 @@ func TestFilter(t *testing.T) { }, }, }, - output: []*datastore.PodMetrics{ + output: []*backendmetrics.FakePodMetrics{ { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -178,10 +178,10 @@ func TestFilter(t *testing.T) { }, // All pods have higher KV cache thant the threshold, so the sheddable request will be // dropped. - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, MaxActiveModels: 2, @@ -192,8 +192,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.85, MaxActiveModels: 2, @@ -204,8 +204,8 @@ func TestFilter(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.85, MaxActiveModels: 2, @@ -215,19 +215,19 @@ func TestFilter(t *testing.T) { }, }, }, - output: []*datastore.PodMetrics{}, + output: []*backendmetrics.FakePodMetrics{}, err: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.filter.Filter(logger, test.req, test.input) + got, err := test.filter.Filter(logger, test.req, toInterface(test.input)) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got); diff != "" { + if diff := cmp.Diff(test.output, toStruct(got)); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -241,44 +241,44 @@ func TestFilterFunc(t *testing.T) { name string f filterFunc req *LLMRequest - input []*datastore.PodMetrics - output []*datastore.PodMetrics + input []*backendmetrics.FakePodMetrics + output []*backendmetrics.FakePodMetrics err bool }{ { name: "least queuing empty input", f: leastQueuingFilterFunc, - input: []*datastore.PodMetrics{}, - output: []*datastore.PodMetrics{}, + input: []*backendmetrics.FakePodMetrics{}, + output: []*backendmetrics.FakePodMetrics{}, }, { name: "least queuing", f: leastQueuingFilterFunc, - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, }, }, }, - output: []*datastore.PodMetrics{ + output: []*backendmetrics.FakePodMetrics{ { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, }, }, @@ -287,37 +287,37 @@ func TestFilterFunc(t *testing.T) { { name: "least kv cache empty input", f: leastKVCacheFilterFunc, - input: []*datastore.PodMetrics{}, - output: []*datastore.PodMetrics{}, + input: []*backendmetrics.FakePodMetrics{}, + output: []*backendmetrics.FakePodMetrics{}, }, { name: "least kv cache", f: leastKVCacheFilterFunc, - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0, }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0.3, }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 1.0, }, }, }, - output: []*datastore.PodMetrics{ + output: []*backendmetrics.FakePodMetrics{ { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0, }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0.3, }, }, @@ -326,32 +326,32 @@ func TestFilterFunc(t *testing.T) { { name: "noQueueAndLessThanKVCacheThresholdPredicate", f: toFilterFunc(noQueueAndLessThanKVCacheThresholdPredicate(0, 0.8)), - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ { // This pod should be returned. - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, }, { // Queue is non zero, despite low kv cache, should not return. - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.3, }, }, { // High kv cache despite zero queue, should not return - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 1.0, }, }, }, - output: []*datastore.PodMetrics{ + output: []*backendmetrics.FakePodMetrics{ { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, @@ -365,10 +365,10 @@ func TestFilterFunc(t *testing.T) { Model: "model", ResolvedTargetModel: "model", }, - input: []*datastore.PodMetrics{ + input: []*backendmetrics.FakePodMetrics{ // ActiveModels include input model, should be returned. { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "model": 1, @@ -377,7 +377,7 @@ func TestFilterFunc(t *testing.T) { }, // Input model is not active, however the server has room to load another adapter. { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "another-model": 1, @@ -386,7 +386,7 @@ func TestFilterFunc(t *testing.T) { }, // Input is not active, and the server has reached max active models. { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "foo": 1, @@ -395,9 +395,9 @@ func TestFilterFunc(t *testing.T) { }, }, }, - output: []*datastore.PodMetrics{ + output: []*backendmetrics.FakePodMetrics{ { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "model": 1, @@ -405,7 +405,7 @@ func TestFilterFunc(t *testing.T) { }, }, { - Metrics: datastore.Metrics{ + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ "another-model": 1, @@ -418,12 +418,12 @@ func TestFilterFunc(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.f(logger, test.req, test.input) + got, err := test.f(logger, test.req, toInterface(test.input)) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got); diff != "" { + if diff := cmp.Diff(test.output, toStruct(got)); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -449,10 +449,10 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { } // Test setup: One affinity pod and one available pod - pods := []*datastore.PodMetrics{ + pods := []*backendmetrics.FakePodMetrics{ { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "affinity-pod"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "affinity-pod"}}, + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ testAffinityModel: 1, @@ -460,8 +460,8 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, }, { - Pod: datastore.Pod{NamespacedName: types.NamespacedName{Name: "available-pod"}}, - Metrics: datastore.Metrics{ + Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "available-pod"}}, + Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{}, }, @@ -476,7 +476,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { // This test should work with whatever value is set there expectedAffinityPercent := loraAffinityThreshold * 100 for i := 0; i < numIterations; i++ { - result, err := loRASoftAffinityFilter(logger, req, pods) + result, err := loRASoftAffinityFilter(logger, req, toInterface(pods)) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -487,7 +487,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { } // Identify if the returned pod is the affinity pod or available pod - if _, exists := result[0].ActiveModels[testAffinityModel]; exists { + if _, exists := result[0].GetMetrics().ActiveModels[testAffinityModel]; exists { affinityCount++ } else { availableCount++ @@ -519,3 +519,22 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { actualAvailablePercent, availableLowerBound, availableUpperBound) } } + +func toInterface(input []*backendmetrics.FakePodMetrics) []backendmetrics.PodMetrics { + output := []backendmetrics.PodMetrics{} + for _, i := range input { + output = append(output, i) + } + return output +} + +func toStruct(input []backendmetrics.PodMetrics) []*backendmetrics.FakePodMetrics { + if input == nil { + return nil + } + output := []*backendmetrics.FakePodMetrics{} + for _, i := range input { + output = append(output, i.(*backendmetrics.FakePodMetrics)) + } + return output +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index bdddd972..82410787 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -24,6 +24,7 @@ import ( "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -97,9 +98,9 @@ var ( // request to make room for critical requests. nextOnFailure: &filter{ name: "drop request", - filter: func(logger logr.Logger, req *LLMRequest, pods []*datastore.PodMetrics) ([]*datastore.PodMetrics, error) { + filter: func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { logger.V(logutil.DEFAULT).Info("Request dropped", "request", req) - return []*datastore.PodMetrics{}, errutil.Error{ + return []backendmetrics.PodMetrics{}, errutil.Error{ Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", } }, @@ -120,16 +121,16 @@ type Scheduler struct { } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod datastore.PodMetrics, err error) { +func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backendmetrics.PodMetrics, err error) { logger := log.FromContext(ctx).WithValues("request", req) podMetrics := s.datastore.PodGetAll() logger.V(logutil.VERBOSE).Info("Scheduling a request", "metrics", podMetrics) pods, err := s.filter.Filter(logger, req, podMetrics) if err != nil || len(pods) == 0 { - return datastore.PodMetrics{}, fmt.Errorf( + return nil, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } logger.V(logutil.VERBOSE).Info("Selecting a random pod from the candidates", "candidatePods", pods) i := rand.Intn(len(pods)) - return *pods[i], nil + return pods[i], nil } diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 5b8269c1..a6c9f1d3 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -31,7 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" tlsutil "sigs.k8s.io/gateway-api-inference-extension/internal/tls" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/controller" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" @@ -45,13 +45,15 @@ type ExtProcServerRunner struct { DestinationEndpointHintKey string PoolName string PoolNamespace string - RefreshMetricsInterval time.Duration - RefreshPrometheusMetricsInterval time.Duration Datastore datastore.Datastore - Provider *backend.Provider SecureServing bool CertPath string UseStreaming bool + RefreshPrometheusMetricsInterval time.Duration + + // This should only be used in tests. We won't need this once we don't inject metrics in the tests. + // TODO:(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/432) Cleanup + TestPodMetricsClient *backendmetrics.FakePodMetricsClient } // Default values for CLI flags in main @@ -73,8 +75,6 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { DestinationEndpointHintMetadataNamespace: DefaultDestinationEndpointHintMetadataNamespace, PoolName: DefaultPoolName, PoolNamespace: DefaultPoolNamespace, - RefreshMetricsInterval: DefaultRefreshMetricsInterval, - RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, SecureServing: DefaultSecureServing, // Datastore can be assigned later. } @@ -121,12 +121,7 @@ func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Man // The runnable implements LeaderElectionRunnable with leader election disabled. func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { return runnable.NoLeaderElection(manager.RunnableFunc(func(ctx context.Context) error { - // Initialize backend provider - if err := r.Provider.Init(ctx, r.RefreshMetricsInterval, r.RefreshPrometheusMetricsInterval); err != nil { - logger.Error(err, "Failed to initialize backend provider") - return err - } - + backendmetrics.StartMetricsLogger(ctx, r.Datastore, r.RefreshPrometheusMetricsInterval) var srv *grpc.Server if r.SecureServing { var cert tls.Certificate diff --git a/pkg/epp/test/benchmark/benchmark.go b/pkg/epp/test/benchmark/benchmark.go deleted file mode 100644 index 67783480..00000000 --- a/pkg/epp/test/benchmark/benchmark.go +++ /dev/null @@ -1,145 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 main - -import ( - "context" - "flag" - "fmt" - "os" - "time" - - "github.com/bojand/ghz/printer" - "github.com/bojand/ghz/runner" - "github.com/go-logr/logr" - "github.com/jhump/protoreflect/desc" - uberzap "go.uber.org/zap" - "google.golang.org/protobuf/proto" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -var ( - svrAddr = flag.String("server_address", fmt.Sprintf("localhost:%d", runserver.DefaultGrpcPort), "Address of the ext proc server") - totalRequests = flag.Int("total_requests", 100000, "number of requests to be sent for load test") - // Flags when running a local ext proc server. - numFakePods = flag.Int("num_fake_pods", 200, "number of fake pods when running a local ext proc server") - numModelsPerPod = flag.Int("num_models_per_pod", 5, "number of fake models per pod when running a local ext proc server") - localServer = flag.Bool("local_server", true, "whether to start a local ext proc server") - refreshPodsInterval = flag.Duration("refreshPodsInterval", 10*time.Second, "interval to refresh pods") - refreshMetricsInterval = flag.Duration("refreshMetricsInterval", 50*time.Millisecond, "interval to refresh metrics via polling pods") - refreshPrometheusMetricsInterval = flag.Duration("refreshPrometheusMetricsInterval", 5*time.Second, "interval to flush prometheus metrics") -) - -const ( - port = runserver.DefaultGrpcPort -) - -func main() { - if err := run(); err != nil { - os.Exit(1) - } -} - -func run() error { - opts := zap.Options{ - Development: true, - } - opts.BindFlags(flag.CommandLine) - flag.Parse() - logger := zap.New(zap.UseFlagOptions(&opts), zap.RawZapOpts(uberzap.AddCaller())) - ctx := log.IntoContext(context.Background(), logger) - - if *localServer { - test.StartExtProc(ctx, port, *refreshPodsInterval, *refreshMetricsInterval, *refreshPrometheusMetricsInterval, fakePods(), fakeModels()) - time.Sleep(time.Second) // wait until server is up - logger.Info("Server started") - } - - report, err := runner.Run( - "envoy.service.ext_proc.v3.ExternalProcessor.Process", - *svrAddr, - runner.WithInsecure(true), - runner.WithBinaryDataFunc(generateRequestFunc(logger)), - runner.WithTotalRequests(uint(*totalRequests)), - ) - if err != nil { - logger.Error(err, "Runner failed") - return err - } - - printer := printer.ReportPrinter{ - Out: os.Stdout, - Report: report, - } - - printer.Print("summary") - return nil -} - -func generateRequestFunc(logger logr.Logger) func(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { - return func(mtd *desc.MethodDescriptor, callData *runner.CallData) []byte { - numModels := *numFakePods * (*numModelsPerPod) - req := test.GenerateRequest(logger, "hello", modelName(int(callData.RequestNumber)%numModels)) - data, err := proto.Marshal(req) - if err != nil { - logutil.Fatal(logger, err, "Failed to marshal request", "request", req) - } - return data - } -} - -func fakeModels() map[string]*v1alpha2.InferenceModel { - models := map[string]*v1alpha2.InferenceModel{} - for i := range *numFakePods { - for j := range *numModelsPerPod { - m := modelName(i*(*numModelsPerPod) + j) - models[m] = &v1alpha2.InferenceModel{Spec: v1alpha2.InferenceModelSpec{ModelName: m}} - } - } - - return models -} - -func fakePods() []*datastore.PodMetrics { - pms := make([]*datastore.PodMetrics, 0, *numFakePods) - for i := 0; i < *numFakePods; i++ { - pms = append(pms, test.FakePodMetrics(i, fakeMetrics(i))) - } - - return pms -} - -// fakeMetrics adds numModelsPerPod number of adapters to the pod metrics. -func fakeMetrics(podNumber int) datastore.Metrics { - metrics := datastore.Metrics{ - ActiveModels: make(map[string]int), - } - for i := 0; i < *numModelsPerPod; i++ { - metrics.ActiveModels[modelName(podNumber*(*numModelsPerPod)+i)] = 0 - } - return metrics -} - -func modelName(i int) string { - return fmt.Sprintf("adapter-%v", i) -} diff --git a/pkg/epp/test/utils.go b/pkg/epp/test/utils.go deleted file mode 100644 index b18b0919..00000000 --- a/pkg/epp/test/utils.go +++ /dev/null @@ -1,126 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 test - -import ( - "context" - "encoding/json" - "fmt" - "net" - "time" - - extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - "github.com/go-logr/logr" - "google.golang.org/grpc" - "google.golang.org/grpc/reflection" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" -) - -func StartExtProc( - ctx context.Context, - port int, - refreshPodsInterval, refreshMetricsInterval, refreshPrometheusMetricsInterval time.Duration, - pods []*datastore.PodMetrics, - models map[string]*v1alpha2.InferenceModel, -) *grpc.Server { - logger := log.FromContext(ctx) - pms := make(map[types.NamespacedName]*datastore.PodMetrics) - for _, pod := range pods { - pms[pod.NamespacedName] = pod - } - pmc := &backend.FakePodMetricsClient{Res: pms} - datastore := datastore.NewDatastore() - for _, m := range models { - datastore.ModelSetIfOlder(m) - } - for _, pm := range pods { - pod := utiltesting.MakePod(pm.NamespacedName.Name). - Namespace(pm.NamespacedName.Namespace). - ReadyCondition(). - IP(pm.Address). - ObjRef() - datastore.PodUpdateOrAddIfNotExist(pod) - datastore.PodUpdateMetricsIfExist(pm.NamespacedName, &pm.Metrics) - } - pp := backend.NewProvider(pmc, datastore) - if err := pp.Init(ctx, refreshMetricsInterval, refreshPrometheusMetricsInterval); err != nil { - logutil.Fatal(logger, err, "Failed to initialize") - } - return startExtProc(logger, port, datastore) -} - -// startExtProc starts an extProc server with fake pods. -func startExtProc(logger logr.Logger, port int, datastore datastore.Datastore) *grpc.Server { - lis, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - logutil.Fatal(logger, err, "Failed to listen", "port", port) - } - - s := grpc.NewServer() - - extProcPb.RegisterExternalProcessorServer(s, handlers.NewServer(scheduling.NewScheduler(datastore), "", "target-pod", datastore)) - - logger.Info("gRPC server starting", "port", port) - reflection.Register(s) - go func() { - err := s.Serve(lis) - if err != nil { - logutil.Fatal(logger, err, "Ext-proc failed with the err") - } - }() - return s -} - -func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.ProcessingRequest { - j := map[string]interface{}{ - "model": model, - "prompt": prompt, - "max_tokens": 100, - "temperature": 0, - } - - llmReq, err := json.Marshal(j) - if err != nil { - logutil.Fatal(logger, err, "Failed to unmarshal LLM request") - } - req := &extProcPb.ProcessingRequest{ - Request: &extProcPb.ProcessingRequest_RequestBody{ - RequestBody: &extProcPb.HttpBody{Body: llmReq}, - }, - } - return req -} - -func FakePodMetrics(index int, metrics datastore.Metrics) *datastore.PodMetrics { - address := fmt.Sprintf("192.168.1.%d", index+1) - pod := datastore.PodMetrics{ - Pod: datastore.Pod{ - NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, - Address: address, - }, - Metrics: metrics, - } - return &pod -} diff --git a/pkg/epp/util/testing/request.go b/pkg/epp/util/testing/request.go new file mode 100644 index 00000000..fe9a0d08 --- /dev/null +++ b/pkg/epp/util/testing/request.go @@ -0,0 +1,45 @@ +/* +Copyright 2025 The Kubernetes 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 testing + +import ( + "encoding/json" + + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.ProcessingRequest { + j := map[string]interface{}{ + "model": model, + "prompt": prompt, + "max_tokens": 100, + "temperature": 0, + } + + llmReq, err := json.Marshal(j) + if err != nil { + logutil.Fatal(logger, err, "Failed to unmarshal LLM request") + } + req := &extProcPb.ProcessingRequest{ + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: llmReq}, + }, + } + return req +} diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go index 2693734f..c4018631 100644 --- a/pkg/epp/util/testing/wrappers.go +++ b/pkg/epp/util/testing/wrappers.go @@ -27,6 +27,12 @@ type PodWrapper struct { corev1.Pod } +func FromBase(pod *corev1.Pod) *PodWrapper { + return &PodWrapper{ + Pod: *pod, + } +} + // MakePod creates a wrapper for a Pod. func MakePod(podName string) *PodWrapper { return &PodWrapper{ diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 765449f3..c5e7c10a 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -55,12 +55,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" - extprocutils "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/test" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" "sigs.k8s.io/yaml" @@ -83,7 +82,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { tests := []struct { name string req *extProcPb.ProcessingRequest - pods []*datastore.PodMetrics + pods map[backendmetrics.Pod]*backendmetrics.Metrics wantHeaders []*configPb.HeaderValueOption wantMetadata *structpb.Struct wantBody []byte @@ -93,21 +92,21 @@ func TestKubeInferenceModelRequest(t *testing.T) { }{ { name: "select lower queue and kv cache, no active lora", - req: extprocutils.GenerateRequest(logger, "test1", "my-model"), + req: utiltesting.GenerateRequest(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. - pods: []*datastore.PodMetrics{ - extprocutils.FakePodMetrics(0, datastore.Metrics{ + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { WaitingQueueSize: 3, KVCacheUsagePercent: 0.2, - }), - extprocutils.FakePodMetrics(1, datastore.Metrics{ + }, + fakePod(1): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.1, - }), - extprocutils.FakePodMetrics(2, datastore.Metrics{ + }, + fakePod(2): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, - }), + }, }, wantHeaders: []*configPb.HeaderValueOption{ { @@ -134,34 +133,34 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "select active lora, low queue", - req: extprocutils.GenerateRequest(logger, "test2", "sql-lora"), + req: utiltesting.GenerateRequest(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. - pods: []*datastore.PodMetrics{ - extprocutils.FakePodMetrics(0, datastore.Metrics{ + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ "foo": 1, "bar": 1, }, - }), - extprocutils.FakePodMetrics(1, datastore.Metrics{ + }, + fakePod(1): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.1, ActiveModels: map[string]int{ "foo": 1, "sql-lora-1fdg2": 1, }, - }), - extprocutils.FakePodMetrics(2, datastore.Metrics{ + }, + fakePod(2): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ "foo": 1, "bar": 1, }, - }), + }, }, wantHeaders: []*configPb.HeaderValueOption{ { @@ -188,34 +187,34 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "select no lora despite active model, avoid excessive queue size", - req: extprocutils.GenerateRequest(logger, "test3", "sql-lora"), + req: utiltesting.GenerateRequest(logger, "test3", "sql-lora"), // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 - pods: []*datastore.PodMetrics{ - extprocutils.FakePodMetrics(0, datastore.Metrics{ + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ "foo": 1, "bar": 1, }, - }), - extprocutils.FakePodMetrics(1, datastore.Metrics{ + }, + fakePod(1): { WaitingQueueSize: 200, KVCacheUsagePercent: 0.1, ActiveModels: map[string]int{ "foo": 1, "sql-lora-1fdg2": 1, }, - }), - extprocutils.FakePodMetrics(2, datastore.Metrics{ + }, + fakePod(2): { WaitingQueueSize: 6, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ "foo": 1, }, - }), + }, }, wantHeaders: []*configPb.HeaderValueOption{ { @@ -242,11 +241,11 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical and all models past threshold, shed request", - req: extprocutils.GenerateRequest(logger, "test4", "sql-lora-sheddable"), + req: utiltesting.GenerateRequest(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. - pods: []*datastore.PodMetrics{ - extprocutils.FakePodMetrics(0, datastore.Metrics{ + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { WaitingQueueSize: 6, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -254,23 +253,23 @@ func TestKubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, - }), - extprocutils.FakePodMetrics(1, datastore.Metrics{ + }, + fakePod(1): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.85, ActiveModels: map[string]int{ "foo": 1, "sql-lora-1fdg3": 1, }, - }), - extprocutils.FakePodMetrics(2, datastore.Metrics{ + }, + fakePod(2): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, ActiveModels: map[string]int{ "foo": 1, "sql-lora-1fdg3": 1, }, - }), + }, }, wantHeaders: []*configPb.HeaderValueOption{}, wantMetadata: &structpb.Struct{}, @@ -285,10 +284,10 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical, but one server has capacity, do not shed", - req: extprocutils.GenerateRequest(logger, "test5", "sql-lora-sheddable"), + req: utiltesting.GenerateRequest(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold - pods: []*datastore.PodMetrics{ - extprocutils.FakePodMetrics(0, datastore.Metrics{ + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ @@ -296,23 +295,23 @@ func TestKubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, - }), - extprocutils.FakePodMetrics(1, datastore.Metrics{ + }, + fakePod(1): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.85, ActiveModels: map[string]int{ "foo": 1, "sql-lora-1fdg3": 1, }, - }), - extprocutils.FakePodMetrics(2, datastore.Metrics{ + }, + fakePod(2): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, ActiveModels: map[string]int{ "foo": 1, "sql-lora-1fdg3": 1, }, - }), + }, }, wantHeaders: []*configPb.HeaderValueOption{ { @@ -391,12 +390,13 @@ func TestKubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - pms := make(map[types.NamespacedName]*datastore.PodMetrics) - for _, pm := range podMetrics { - pms[pm.NamespacedName] = pm +func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + // Reconfigure the TestPodMetricsClient. + res := map[types.NamespacedName]*backendmetrics.Metrics{} + for pod, metrics := range podAndMetrics { + res[pod.NamespacedName] = metrics } - pmc := &backend.FakePodMetricsClient{Res: pms} + serverRunner.TestPodMetricsClient.SetRes(res) serverCtx, stopServer := context.WithCancel(context.Background()) @@ -405,27 +405,26 @@ func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (clie "app": "vllm-llama2-7b-pool", } - for _, pm := range podMetrics { - pod := utiltesting.MakePod(pm.NamespacedName.Name). - Namespace(pm.NamespacedName.Namespace). + for pod := range podAndMetrics { + pod := utiltesting.MakePod(pod.NamespacedName.Name). + Namespace(pod.NamespacedName.Namespace). ReadyCondition(). Labels(podLabels). - IP(pm.Address). + IP(pod.Address). Complete(). ObjRef() copy := pod.DeepCopy() if err := k8sClient.Create(context.Background(), copy); err != nil { - logutil.Fatal(logger, err, "Failed to create pod", "pod", pm.NamespacedName) + logutil.Fatal(logger, err, "Failed to create pod", "pod", pod) } // since no pod controllers deployed in fake environment, we manually update pod status copy.Status = pod.Status if err := k8sClient.Status().Update(context.Background(), copy); err != nil { - logutil.Fatal(logger, err, "Failed to update pod status", "pod", pm.NamespacedName) + logutil.Fatal(logger, err, "Failed to update pod status", "pod", pod) } } - serverRunner.Provider = backend.NewProvider(pmc, serverRunner.Datastore) go func() { if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { logutil.Fatal(logger, err, "Failed to start ext-proc server") @@ -434,7 +433,7 @@ func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (clie // check if all pods are synced to datastore assert.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podMetrics), "Datastore not synced") + assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podAndMetrics), "Datastore not synced") }, 10*time.Second, time.Second) address := fmt.Sprintf("localhost:%v", port) @@ -455,12 +454,12 @@ func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (clie stopServer() // clear created pods - for _, pm := range podMetrics { - pod := utiltesting.MakePod(pm.NamespacedName.Name). - Namespace(pm.NamespacedName.Namespace).Complete().ObjRef() + for pod := range podAndMetrics { + pod := utiltesting.MakePod(pod.NamespacedName.Name). + Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() if err := k8sClient.Delete(context.Background(), pod); err != nil { - logutil.Fatal(logger, err, "Failed to delete pod", "pod", pm.NamespacedName) + logutil.Fatal(logger, err, "Failed to delete pod", "pod", fakePod) } } // wait a little until the goroutines actually exit @@ -468,6 +467,13 @@ func setUpHermeticServer(t *testing.T, podMetrics []*datastore.PodMetrics) (clie } } +func fakePod(index int) backendmetrics.Pod { + return backendmetrics.Pod{ + NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, + Address: fmt.Sprintf("192.168.1.%d", index+1), + } +} + // Sets up a test environment and returns the runner struct func BeforeSuit(t *testing.T) func() { // Set up mock k8s API Client @@ -503,9 +509,11 @@ func BeforeSuit(t *testing.T) func() { } serverRunner = runserver.NewDefaultExtProcServerRunner() + serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} + pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) // Adjust from defaults serverRunner.PoolName = "vllm-llama2-7b-pool" - serverRunner.Datastore = datastore.NewDatastore() + serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) serverRunner.SecureServing = false if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { From 1dc768f171add007baba46873eeaad1aa40f1152 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 10 Mar 2025 23:09:47 +0200 Subject: [PATCH 083/260] fixed broken link (#467) Signed-off-by: Nir Rozenbaum --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c500602c..6ad19cdb 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ As Inference Gateway builds towards a GA release. We will continue to expand our ## End-to-End Tests -Follow this [README](./test/e2e/README.md) to learn more about running the inference-extension end-to-end test suite on your cluster. +Follow this [README](./test/e2e/epp/README.md) to learn more about running the inference-extension end-to-end test suite on your cluster. ## Contributing From 910407e758915a8025878038d36c695b035cc532 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 10 Mar 2025 23:23:46 +0200 Subject: [PATCH 084/260] fixed minimal requirement for envoy version (#466) Signed-off-by: Nir Rozenbaum --- site-src/guides/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index b7b31000..8bcee6e2 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -3,7 +3,7 @@ This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get a first, single InferencePool up and running! ## **Prerequisites** - - Envoy Gateway [v1.2.1](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher + - Envoy Gateway [v1.3.0](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - A cluster with: - Support for services of typs `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). From 59a772d6f404257caf63f87212700ca44ec90361 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 16:49:47 -0700 Subject: [PATCH 085/260] Bump github.com/onsi/ginkgo/v2 from 2.22.2 to 2.23.0 (#473) Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.22.2 to 2.23.0. - [Release notes](https://github.com/onsi/ginkgo/releases) - [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md) - [Commits](https://github.com/onsi/ginkgo/compare/v2.22.2...v2.23.0) --- updated-dependencies: - dependency-name: github.com/onsi/ginkgo/v2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 34 ++++++++------------- go.sum | 95 +++++++++++++--------------------------------------------- 2 files changed, 33 insertions(+), 96 deletions(-) diff --git a/go.mod b/go.mod index 91173449..3342d001 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,11 @@ go 1.23.0 toolchain go1.23.2 require ( - github.com/bojand/ghz v0.120.0 github.com/elastic/crd-ref-docs v0.1.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.7.0 - github.com/jhump/protoreflect v1.17.0 - github.com/onsi/ginkgo/v2 v2.22.2 + github.com/onsi/ginkgo/v2 v2.23.0 github.com/onsi/gomega v1.36.2 github.com/prometheus/client_golang v1.21.0 github.com/prometheus/client_model v0.6.1 @@ -35,26 +33,18 @@ require ( require ( cel.dev/expr v0.19.0 // indirect - cloud.google.com/go/compute/metadata v0.5.2 // indirect - github.com/BurntSushi/toml v1.1.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect - github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect - github.com/Masterminds/sprig/v3 v3.2.3 // indirect - github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bufbuild/protocompile v0.14.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fatih/color v1.16.0 // indirect @@ -66,6 +56,8 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobuffalo/flect v1.0.2 // indirect github.com/goccy/go-yaml v1.11.3 // indirect @@ -82,11 +74,11 @@ require ( github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.11 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jinzhu/configor v1.2.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/leodido/go-urn v1.2.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -101,8 +93,6 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/shopspring/decimal v1.2.0 // indirect - github.com/spf13/cast v1.4.1 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect @@ -115,17 +105,17 @@ require ( go.opentelemetry.io/otel/sdk v1.32.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/crypto v0.32.0 // indirect + golang.org/x/crypto v0.33.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.34.0 // indirect + golang.org/x/mod v0.23.0 // indirect + golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.28.0 // indirect + golang.org/x/tools v0.30.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a // indirect diff --git a/go.sum b/go.sum index f55f404b..2e18e4ad 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,11 @@ cel.dev/expr v0.19.0 h1:lXuo+nDhpyJSpWxpPVi5cPUwzKb+dsdOiw6IreM5yt0= cel.dev/expr v0.19.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= -cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= -github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= -github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= -github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= -github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -27,10 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bojand/ghz v0.120.0 h1:6F4wsmZVwFg5UnD+/R+IABWk6sKE/0OKIBdUQUZnOdo= -github.com/bojand/ghz v0.120.0/go.mod h1:HfECuBZj1v02XObGnRuoZgyB1PR24/25dIYiJIMjJnE= -github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= -github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -43,18 +28,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/crd-ref-docs v0.1.0 h1:Cr5kz89QB3Iuuj7dhAfLMApCrChEGAaIBTxGk/xuRKw= github.com/elastic/crd-ref-docs v0.1.0/go.mod h1:X83mMBdJt05heJUYiS3T0yJ/JkCuliuhSUNav5Gjo/U= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= @@ -84,11 +63,11 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= -github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -115,7 +94,6 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -128,10 +106,6 @@ github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= -github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= -github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko= -github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -158,10 +132,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= @@ -179,8 +151,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ= +github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -201,11 +173,6 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= -github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -215,9 +182,8 @@ github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8w github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -227,7 +193,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= @@ -255,66 +220,49 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -340,7 +288,6 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= From af7fc38c6ecf404a096703ff3e835ca02dd5d677 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:43:46 -0700 Subject: [PATCH 086/260] Bump sigs.k8s.io/controller-runtime from 0.20.2 to 0.20.3 (#470) Bumps [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) from 0.20.2 to 0.20.3. - [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases) - [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.20.2...v0.20.3) --- updated-dependencies: - dependency-name: sigs.k8s.io/controller-runtime dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3342d001..a2ba0a3d 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( k8s.io/code-generator v0.32.2 k8s.io/component-base v0.32.2 k8s.io/utils v0.0.0-20241210054802-24370beab758 - sigs.k8s.io/controller-runtime v0.20.2 + sigs.k8s.io/controller-runtime v0.20.3 sigs.k8s.io/structured-merge-diff/v4 v4.5.0 sigs.k8s.io/yaml v1.4.0 ) diff --git a/go.sum b/go.sum index 2e18e4ad..90018f15 100644 --- a/go.sum +++ b/go.sum @@ -318,8 +318,8 @@ k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJ k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.20.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= -sigs.k8s.io/controller-runtime v0.20.2/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/controller-runtime v0.20.3 h1:I6Ln8JfQjHH7JbtCD2HCYHoIzajoRxPNuvhvcDbZgkI= +sigs.k8s.io/controller-runtime v0.20.3/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A= sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= From b343b2f909358de140e02952e384feab2111bde3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:57:46 -0700 Subject: [PATCH 087/260] Bump google.golang.org/grpc from 1.70.0 to 1.71.0 (#471) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.70.0 to 1.71.0. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.70.0...v1.71.0) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 21 +++++++++++---------- go.sum | 50 ++++++++++++++++++++++++++------------------------ 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index a2ba0a3d..ef3d4b8d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 - google.golang.org/grpc v1.70.0 + google.golang.org/grpc v1.71.0 google.golang.org/protobuf v1.36.5 k8s.io/api v0.32.2 k8s.io/apiextensions-apiserver v0.32.2 @@ -32,7 +32,7 @@ require ( ) require ( - cel.dev/expr v0.19.0 // indirect + cel.dev/expr v0.19.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible // indirect @@ -42,7 +42,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect + github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect @@ -97,19 +97,20 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect - go.opentelemetry.io/otel v1.32.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect - go.opentelemetry.io/otel/metric v1.32.0 // indirect - go.opentelemetry.io/otel/sdk v1.32.0 // indirect - go.opentelemetry.io/otel/trace v1.32.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.23.0 // indirect golang.org/x/net v0.35.0 // indirect - golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect @@ -118,8 +119,8 @@ require ( golang.org/x/tools v0.30.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 90018f15..f4869047 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.19.0 h1:lXuo+nDhpyJSpWxpPVi5cPUwzKb+dsdOiw6IreM5yt0= -cel.dev/expr v0.19.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -20,8 +20,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI= -github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q= +github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -170,8 +170,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -193,22 +193,24 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= -go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= -go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -234,8 +236,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= -golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -271,12 +273,12 @@ golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSm golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a h1:OAiGFfOiA0v9MRYsSidp3ubZaBnteRUyn3xB2ZQ5G/E= -google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a/go.mod h1:jehYqy3+AhJU9ve55aNOaSml7wUXjF9x6z2LcCfpAhY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 0bef35b31c683834a37bcc5428629d76b9ba39b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:57:53 -0700 Subject: [PATCH 088/260] Bump github.com/prometheus/client_golang from 1.21.0 to 1.21.1 (#474) Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.0 to 1.21.1. - [Release notes](https://github.com/prometheus/client_golang/releases) - [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md) - [Commits](https://github.com/prometheus/client_golang/compare/v1.21.0...v1.21.1) --- updated-dependencies: - dependency-name: github.com/prometheus/client_golang dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ef3d4b8d..a9b34d86 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/onsi/ginkgo/v2 v2.23.0 github.com/onsi/gomega v1.36.2 - github.com/prometheus/client_golang v1.21.0 + github.com/prometheus/client_golang v1.21.1 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.62.0 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index f4869047..46a731b6 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= -github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= From 9e7d983ee29de55f2be9581826b3a17be6e4940f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 11:45:46 -0700 Subject: [PATCH 089/260] Bump sigs.k8s.io/structured-merge-diff/v4 from 4.5.0 to 4.6.0 (#472) Bumps [sigs.k8s.io/structured-merge-diff/v4](https://github.com/kubernetes-sigs/structured-merge-diff) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/kubernetes-sigs/structured-merge-diff/releases) - [Changelog](https://github.com/kubernetes-sigs/structured-merge-diff/blob/master/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/structured-merge-diff/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: sigs.k8s.io/structured-merge-diff/v4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a9b34d86..13ad16c4 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( k8s.io/component-base v0.32.2 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.3 - sigs.k8s.io/structured-merge-diff/v4 v4.5.0 + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 sigs.k8s.io/yaml v1.4.0 ) diff --git a/go.sum b/go.sum index 46a731b6..463e55ff 100644 --- a/go.sum +++ b/go.sum @@ -326,7 +326,9 @@ sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= -sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016 h1:kXv6kKdoEtedwuqMmkqhbkgvYKeycVbC8+iPCP9j5kQ= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= From b01bfda3b365afaafdd0d5d5ea8f2b8629f8c2ae Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Tue, 11 Mar 2025 18:21:46 -0400 Subject: [PATCH 090/260] Handle request trailers (#477) --- pkg/body-based-routing/handlers/server.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/body-based-routing/handlers/server.go index 434dd530..813c55c8 100644 --- a/pkg/body-based-routing/handlers/server.go +++ b/pkg/body-based-routing/handlers/server.go @@ -67,6 +67,8 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { resp, err = s.HandleRequestHeaders(req.GetRequestHeaders()) case *extProcPb.ProcessingRequest_RequestBody: resp, err = s.HandleRequestBody(ctx, req.GetRequestBody()) + case *extProcPb.ProcessingRequest_RequestTrailers: + resp, err = s.HandleRequestTrailers(req.GetRequestTrailers()) case *extProcPb.ProcessingRequest_ResponseHeaders: resp, err = s.HandleResponseHeaders(req.GetResponseHeaders()) case *extProcPb.ProcessingRequest_ResponseBody: From 6b117dfe23e605f355d75620c17479cfff4f97f7 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 11 Mar 2025 17:33:46 -0700 Subject: [PATCH 091/260] Add the base model to InferenceModel sample manifest (#479) --- config/manifests/inferencemodel.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 94c36d84..12fb00b7 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -10,3 +10,14 @@ spec: targetModels: - name: tweet-summary-1 weight: 100 + +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-base-model +spec: + modelName: meta-llama/Llama-2-7b-hf + criticality: Critical + poolRef: + name: my-pool From 07df6313148a9164b864c9981880bc46b6923dc8 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 11 Mar 2025 17:53:46 -0700 Subject: [PATCH 092/260] Fix metrics debug log; change metrics client log level to reduce spam (#478) --- pkg/epp/backend/metrics/fake.go | 4 ++++ pkg/epp/backend/metrics/logger.go | 4 +++- pkg/epp/backend/metrics/pod_metrics.go | 5 +++++ pkg/epp/backend/metrics/types.go | 8 ++++++++ pkg/epp/backend/vllm/metrics.go | 13 ++++++------- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/pkg/epp/backend/metrics/fake.go b/pkg/epp/backend/metrics/fake.go index fae7149d..7fd4970d 100644 --- a/pkg/epp/backend/metrics/fake.go +++ b/pkg/epp/backend/metrics/fake.go @@ -34,6 +34,10 @@ type FakePodMetrics struct { Metrics *Metrics } +func (fpm *FakePodMetrics) String() string { + return fmt.Sprintf("Pod: %v; Metrics: %v", fpm.GetPod(), fpm.GetMetrics()) +} + func (fpm *FakePodMetrics) GetPod() *Pod { return fpm.Pod } diff --git a/pkg/epp/backend/metrics/logger.go b/pkg/epp/backend/metrics/logger.go index 664115eb..74735755 100644 --- a/pkg/epp/backend/metrics/logger.go +++ b/pkg/epp/backend/metrics/logger.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "fmt" "time" "github.com/go-logr/logr" @@ -76,7 +77,8 @@ func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometh podsWithStaleMetrics := datastore.PodList(func(pm PodMetrics) bool { return time.Since(pm.GetMetrics().UpdateTime) > metricsValidityPeriod }) - logger.Info("Current Pods and metrics gathered", "fresh metrics", podsWithFreshMetrics, "stale metrics", podsWithStaleMetrics) + s := fmt.Sprintf("Current Pods and metrics gathered. Fresh metrics: %+v, Stale metrics: %+v", podsWithFreshMetrics, podsWithStaleMetrics) + logger.Info(s) } } }() diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index f76c2e8c..b954a98c 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "fmt" "sync" "sync/atomic" "time" @@ -52,6 +53,10 @@ type PodMetricsClient interface { FetchMetrics(ctx context.Context, pod *Pod, existing *Metrics, port int32) (*Metrics, error) } +func (pm *podMetrics) String() string { + return fmt.Sprintf("Pod: %v; Metrics: %v", pm.GetPod(), pm.GetMetrics()) +} + func (pm *podMetrics) GetPod() *Pod { return (*Pod)(atomic.LoadPointer(&pm.pod)) } diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index cdbdb2ce..fd600163 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -62,6 +62,7 @@ type PodMetrics interface { GetMetrics() *Metrics UpdatePod(*corev1.Pod) StopRefreshLoop() + String() string } type Pod struct { @@ -69,6 +70,13 @@ type Pod struct { Address string } +func (p *Pod) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("%+v", *p) +} + type Metrics struct { // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. ActiveModels map[string]int diff --git a/pkg/epp/backend/vllm/metrics.go b/pkg/epp/backend/vllm/metrics.go index f83326eb..8d2dd715 100644 --- a/pkg/epp/backend/vllm/metrics.go +++ b/pkg/epp/backend/vllm/metrics.go @@ -61,8 +61,7 @@ func (p *PodMetricsClientImpl) FetchMetrics( existing *metrics.Metrics, port int32, ) (*metrics.Metrics, error) { - logger := log.FromContext(ctx) - loggerDefault := logger.V(logutil.DEFAULT) + logger := log.FromContext(ctx).V(logutil.TRACE) // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. @@ -70,12 +69,12 @@ func (p *PodMetricsClientImpl) FetchMetrics( req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - loggerDefault.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) + logger.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) return nil, fmt.Errorf("failed to create request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { - loggerDefault.Error(err, "Failed to fetch metrics", "pod", pod.NamespacedName) + logger.Error(err, "Failed to fetch metrics", "pod", pod.NamespacedName) return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod.NamespacedName, err) } defer func() { @@ -83,7 +82,7 @@ func (p *PodMetricsClientImpl) FetchMetrics( }() if resp.StatusCode != http.StatusOK { - loggerDefault.Error(nil, "Unexpected status code returned", "pod", pod.NamespacedName, "statusCode", resp.StatusCode) + logger.Error(nil, "Unexpected status code returned", "pod", pod.NamespacedName, "statusCode", resp.StatusCode) return nil, fmt.Errorf("unexpected status code from %s: %v", pod.NamespacedName, resp.StatusCode) } @@ -172,7 +171,7 @@ func promToPodMetrics( func getLatestLoraMetric(logger logr.Logger, metricFamilies map[string]*dto.MetricFamily) (*dto.Metric, time.Time, error) { loraRequests, ok := metricFamilies[LoraRequestInfoMetricName] if !ok { - logger.V(logutil.DEFAULT).Error(nil, "Metric family not found", "name", LoraRequestInfoMetricName) + logger.V(logutil.TRACE).Error(nil, "Metric family not found", "name", LoraRequestInfoMetricName) return nil, time.Time{}, fmt.Errorf("metric family %q not found", LoraRequestInfoMetricName) } @@ -219,7 +218,7 @@ func getLatestLoraMetric(logger logr.Logger, metricFamilies map[string]*dto.Metr func getLatestMetric(logger logr.Logger, metricFamilies map[string]*dto.MetricFamily, metricName string) (*dto.Metric, error) { mf, ok := metricFamilies[metricName] if !ok { - logger.V(logutil.DEFAULT).Error(nil, "Metric family not found", "name", metricName) + logger.V(logutil.TRACE).Error(nil, "Metric family not found", "name", metricName) return nil, fmt.Errorf("metric family %q not found", metricName) } if len(mf.GetMetric()) == 0 { From 32e03eca0285cd837f73322290ef940218131f21 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 12 Mar 2025 13:47:47 -0700 Subject: [PATCH 093/260] Add support for OpenAI API streaming protocol (#469) * Add support for OpenAI API streaming protocol * Add streaming integration tests * reverting go mod changes * Uncommenting previous tests * fix errant typo * Updating test infra to work for multiple tests * Always marshal responseBody, add test case to check for this --- .golangci.yml | 1 - Makefile | 2 +- pkg/epp/handlers/streamingserver.go | 98 +- pkg/epp/server/controller_manager.go | 11 +- pkg/epp/util/testing/request.go | 24 +- test/integration/epp/hermetic_test.go | 1272 +++++++++++++++-- .../inferencepool-with-model-hermetic.yaml | 11 + 7 files changed, 1293 insertions(+), 126 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 2ad3b93d..d1b1e112 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -25,7 +25,6 @@ linters: - makezero - errcheck - goconst - - gocyclo - gofmt - goimports - gosimple diff --git a/Makefile b/Makefile index 257d2cbb..40cb0b75 100644 --- a/Makefile +++ b/Makefile @@ -123,7 +123,7 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: test-integration test-integration: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./test/integration -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./test/integration/epp/... -race -coverprofile cover.out .PHONY: test-e2e test-e2e: ## Run end-to-end tests against an existing Kubernetes cluster with at least 3 available GPUs. diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index c8de7bb7..2aaca7f3 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strconv" + "strings" "time" configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" @@ -131,9 +132,14 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) case *extProcPb.ProcessingRequest_ResponseHeaders: loggerVerbose.Info("got response headers", "headers", v.ResponseHeaders.Headers.GetHeaders()) for _, header := range v.ResponseHeaders.Headers.GetHeaders() { - code := header.RawValue[0] - if header.Key == "status" && string(code) != "200" { + value := string(header.RawValue) + logger.Error(nil, "header", "key", header.Key, "value", value) + if header.Key == "status" && value != "200" { reqCtx.ResponseStatusCode = errutil.ModelServerError + } else if header.Key == "content-type" && strings.Contains(value, "text/event-stream") { + reqCtx.modelServerStreaming = true + loggerVerbose.Info("model server is streaming response") + logger.Error(nil, "made it here") } } reqCtx.RequestState = ResponseRecieved @@ -158,36 +164,57 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) } case *extProcPb.ProcessingRequest_ResponseBody: - go func() { - _, err := writer.Write(v.ResponseBody.Body) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error populating writer") - } - }() - - // Message is buffered, we can read and decode. - if v.ResponseBody.EndOfStream { - err = decoder.Decode(&responseBody) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + if reqCtx.modelServerStreaming { + // Currently we punt on response parsing if the modelServer is streaming, and we just passthrough. + reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: v.ResponseBody.Body, + EndOfStream: v.ResponseBody.EndOfStream, + }, + }, + }, + }, + }, + }, } - // Body stream complete. Close the reader pipe. - reader.Close() - - reqCtx, err = s.HandleResponseBody(ctx, reqCtx, responseBody) - if err == nil && reqCtx.ResponseComplete { - reqCtx.ResponseCompleteTimestamp = time.Now() - metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) - metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) - metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) - metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) + } else { + go func() { + _, err := writer.Write(v.ResponseBody.Body) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error populating writer") + } + }() + + // Message is buffered, we can read and decode. + if v.ResponseBody.EndOfStream { + err = decoder.Decode(&responseBody) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + } + // Body stream complete. Close the reader pipe. + reader.Close() + + reqCtx, err = s.HandleResponseBody(ctx, reqCtx, responseBody) + if err == nil && reqCtx.ResponseComplete { + reqCtx.ResponseCompleteTimestamp = time.Now() + metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) + metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) + metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) + metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) + } + loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) } - loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) } case *extProcPb.ProcessingRequest_ResponseTrailers: // This is currently unused. } + // Handle the err and fire an immediate response. if err != nil { logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) resp, err := BuildErrResponse(err) @@ -246,7 +273,11 @@ func (r *StreamingRequestContext) updateStateAndSendIfNeeded(srv extProcPb.Exter if err := srv.Send(r.respBodyResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } - r.RequestState = BodyResponseResponsesComplete + + body := r.respBodyResp.Response.(*extProcPb.ProcessingResponse_ResponseBody) + if body.ResponseBody.Response.GetBodyMutation().GetStreamedResponse().GetEndOfStream() { + r.RequestState = BodyResponseResponsesComplete + } // Dump the response so a new stream message can begin r.reqBodyResp = nil } @@ -273,6 +304,8 @@ type StreamingRequestContext struct { ResponseComplete bool ResponseStatusCode string + modelServerStreaming bool + reqHeaderResp *extProcPb.ProcessingResponse reqBodyResp *extProcPb.ProcessingResponse reqTrailerResp *extProcPb.ProcessingResponse @@ -339,14 +372,15 @@ func (s *StreamingServer) HandleRequestBody( // Update target models in the body. if llmReq.Model != llmReq.ResolvedTargetModel { requestBodyMap["model"] = llmReq.ResolvedTargetModel - requestBodyBytes, err = json.Marshal(requestBodyMap) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") - return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} - } - loggerVerbose.Info("Updated request body marshalled", "body", string(requestBodyBytes)) } + requestBodyBytes, err = json.Marshal(requestBodyMap) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") + return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} + } + loggerVerbose.Info("Updated request body marshalled", "body", string(requestBodyBytes)) + target, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { return reqCtx, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index fd505d00..46694f7b 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -28,6 +28,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -40,7 +41,7 @@ func init() { // NewDefaultManager creates a new controller manager with default configuration. func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Manager, error) { - manager, err := ctrl.NewManager(restConfig, ctrl.Options{ + defaultOpts := ctrl.Options{ Scheme: scheme, Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ @@ -65,7 +66,13 @@ func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Ma }, }, }, - }) + } + return NewManagerWithOptions(restConfig, defaultOpts) +} + +// NewManagerWithOptions creates a new controller manager with injectable options. +func NewManagerWithOptions(restConfig *rest.Config, opts manager.Options) (ctrl.Manager, error) { + manager, err := ctrl.NewManager(restConfig, opts) if err != nil { return nil, fmt.Errorf("failed to create controller manager: %v", err) } diff --git a/pkg/epp/util/testing/request.go b/pkg/epp/util/testing/request.go index fe9a0d08..30772ad5 100644 --- a/pkg/epp/util/testing/request.go +++ b/pkg/epp/util/testing/request.go @@ -19,6 +19,7 @@ package testing import ( "encoding/json" + envoyCorev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/go-logr/logr" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -38,8 +39,29 @@ func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.Proces } req := &extProcPb.ProcessingRequest{ Request: &extProcPb.ProcessingRequest_RequestBody{ - RequestBody: &extProcPb.HttpBody{Body: llmReq}, + RequestBody: &extProcPb.HttpBody{Body: llmReq, EndOfStream: true}, }, } return req } + +func GenerateStreamedRequestSet(logger logr.Logger, prompt, model string) []*extProcPb.ProcessingRequest { + requests := []*extProcPb.ProcessingRequest{} + headerReq := &extProcPb.ProcessingRequest{ + Request: &extProcPb.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extProcPb.HttpHeaders{ + Headers: &envoyCorev3.HeaderMap{ + Headers: []*envoyCorev3.HeaderValue{ + { + Key: "hi", + Value: "mom", + }, + }, + }, + }, + }, + } + requests = append(requests, headerReq) + requests = append(requests, GenerateRequest(logger, prompt, model)) + return requests +} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index c5e7c10a..7dc9bdb8 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -43,6 +43,8 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -51,7 +53,10 @@ import ( "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" @@ -78,6 +83,13 @@ var ( logger = logutil.NewTestLogger().V(logutil.VERBOSE) ) +func TestMain(m *testing.M) { + cleanup := BeforeSuite() + code := m.Run() + cleanup() + os.Exit(code) +} + func TestKubeInferenceModelRequest(t *testing.T) { tests := []struct { name string @@ -196,57 +208,814 @@ func TestKubeInferenceModelRequest(t *testing.T) { WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, + "foo": 1, + "bar": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 200, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + }, + }, + }, + wantHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: runserver.DefaultDestinationEndpointHintKey, + RawValue: []byte("192.168.1.3:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte("76"), + }, + }, + }, + wantMetadata: makeMetadata("192.168.1.3:8000"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `, + wantErr: false, + }, + { + name: "noncritical and all models past threshold, shed request", + req: utiltesting.GenerateRequest(logger, "test4", "sql-lora-sheddable"), + // no pods will be picked as all models are either above kv threshold, + // queue threshold, or both. + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + wantHeaders: []*configPb.HeaderValueOption{}, + wantMetadata: &structpb.Struct{}, + wantBody: []byte(""), + wantErr: false, + immediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_TooManyRequests, + }, + }, + wantMetrics: "", + }, + { + name: "noncritical, but one server has capacity, do not shed", + req: utiltesting.GenerateRequest(logger, "test5", "sql-lora-sheddable"), + // pod 0 will be picked as all other models are above threshold + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + wantHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: runserver.DefaultDestinationEndpointHintKey, + RawValue: []byte("192.168.1.1:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte("76"), + }, + }, + }, + wantMetadata: makeMetadata("192.168.1.1:8000"), + wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 + `, + wantErr: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, cleanup := setUpHermeticServer(t, test.pods, false) + t.Cleanup(cleanup) + want := &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: test.wantHeaders, + }, + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_Body{ + Body: test.wantBody, + }, + }, + }, + }, + }, + DynamicMetadata: test.wantMetadata, + } + res, err := sendRequest(t, client, test.req) + + if err != nil && !test.wantErr { + t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) + } + if test.immediateResponse != nil { + want = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: test.immediateResponse, + }, + } + } + if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { + t.Errorf("Unexpected response, (-want +got): %v", diff) + } + + if test.wantMetrics != "" { + if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(test.wantMetrics), "inference_model_request_total"); err != nil { + t.Error(err) + } + } + + legacyregistry.Reset() + }) + } +} + +func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { + tests := []struct { + name string + requests []*extProcPb.ProcessingRequest + pods map[backendmetrics.Pod]*backendmetrics.Metrics + wantResponses []*extProcPb.ProcessingResponse + wantMetrics string + wantErr bool + immediateResponse *extProcPb.ImmediateResponse + }{ + // Request flow tests + { + name: "select lower queue and kv cache, no active lora", + requests: utiltesting.GenerateStreamedRequestSet(logger, "test1", "my-model"), + // pod-1 will be picked because it has relatively low queue size and low KV cache. + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.2, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + }, + }, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 + `, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.2:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(76)), + }, + }, + }}, + }, + }, + }, + DynamicMetadata: makeMetadata("192.168.1.2:8000"), + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "select active lora, low queue", + requests: utiltesting.GenerateStreamedRequestSet(logger, "test2", "sql-lora"), + // pod-1 will be picked because it has relatively low queue size, with the requested + // model being active, and has low KV cache. + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + }, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.2:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(76)), + }, + }, + }}, + }, + }, + }, + DynamicMetadata: makeMetadata("192.168.1.2:8000"), + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "select no lora despite active model, avoid excessive queue size", + requests: utiltesting.GenerateStreamedRequestSet(logger, "test3", "sql-lora"), + // pod-2 will be picked despite it NOT having the requested model being active + // as it's above the affinity for queue size. Also is critical, so we should + // still honor request despite all queues > 5 + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 200, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + }, + }, + }, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.3:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(76)), + }, + }, + }}, + }, + }, + }, + DynamicMetadata: makeMetadata("192.168.1.3:8000"), + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "noncritical and all models past threshold, shed request", + requests: utiltesting.GenerateStreamedRequestSet(logger, "test4", "sql-lora-sheddable"), + // no pods will be picked as all models are either above kv threshold, + // queue threshold, or both. + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 6, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + wantErr: false, + wantMetrics: "", + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_TooManyRequests, + }, + }, + }, + }, + }, + }, + { + name: "noncritical, but one server has capacity, do not shed", + requests: utiltesting.GenerateStreamedRequestSet(logger, "test5", "sql-lora-sheddable"), + // pod 0 will be picked as all other models are above threshold + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 + `, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.1:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(76)), + }, + }, + }}, + }, + }, + }, + DynamicMetadata: makeMetadata("192.168.1.1:8000"), + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "body sent over multiple requests, noncritical, but one server has capacity, do not shed", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "hi", + Value: "mom", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lo"), EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("ra-sheddable\",\"prompt\":\"test6\",\"temperature\":0}"), EndOfStream: true}, + }, + }, + }, + + // + // pod 0 will be picked as all other models are above threshold + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 + `, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.1:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(76)), + }, + }, + }}, + }, + }, + }, + DynamicMetadata: makeMetadata("192.168.1.1:8000"), + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test6\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "inferencemodel's modelName is not translated, passthrough", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "hi", + Value: "mom", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("{\"max_tokens\":100,\"model\":\"direct-"), EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("model\",\"prompt\":\"test6\",\"temperature\":0}"), EndOfStream: true}, + }, + }, + }, + + // + // pod 0 will be picked as all other models are above threshold + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, }, }, fakePod(1): { - WaitingQueueSize: 200, - KVCacheUsagePercent: 0.1, + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, ActiveModels: map[string]int{ "foo": 1, - "sql-lora-1fdg2": 1, + "sql-lora-1fdg3": 1, }, }, fakePod(2): { - WaitingQueueSize: 6, - KVCacheUsagePercent: 0.2, + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, ActiveModels: map[string]int{ - "foo": 1, + "foo": 1, + "sql-lora-1fdg3": 1, }, }, }, - wantHeaders: []*configPb.HeaderValueOption{ + wantMetrics: ` + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="direct-model",target_model_name="direct-model"} 1 + `, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ { - Header: &configPb.HeaderValue{ - Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("192.168.1.3:8000"), + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.2:8000"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(74)), + }, + }, + }}, + }, + }, }, + DynamicMetadata: makeMetadata("192.168.1.2:8000"), }, { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte("76"), + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"direct-model\",\"prompt\":\"test6\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, }, }, }, - wantMetadata: makeMetadata("192.168.1.3:8000"), - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), - wantMetrics: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `, - wantErr: false, }, + // Response flow tests { - name: "noncritical and all models past threshold, shed request", - req: utiltesting.GenerateRequest(logger, "test4", "sql-lora-sheddable"), - // no pods will be picked as all models are either above kv threshold, - // queue threshold, or both. + name: "responsebody sent over multiple requests, content-type is json, buffer", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_ResponseHeaders{ + ResponseHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "content-type", + Value: "application/json", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lo"), EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{Body: []byte("ra-sheddable\",\"prompt\":\"test6\",\"temperature\":0}"), EndOfStream: true}, + }, + }, + }, + + // + // pod 0 will be picked as all other models are above threshold pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ fakePod(0): { - WaitingQueueSize: 6, + WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, ActiveModels: map[string]int{ "foo": 1, @@ -271,20 +1040,74 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantHeaders: []*configPb.HeaderValueOption{}, - wantMetadata: &structpb.Struct{}, - wantBody: []byte(""), - wantErr: false, - immediateResponse: &extProcPb.ImmediateResponse{ - Status: &envoyTypePb.HttpStatus{ - Code: envoyTypePb.StatusCode_TooManyRequests, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-sheddable\",\"prompt\":\"test6\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, }, }, - wantMetrics: "", }, { - name: "noncritical, but one server has capacity, do not shed", - req: utiltesting.GenerateRequest(logger, "test5", "sql-lora-sheddable"), + name: "responsebody sent over a single request, but empty body with EndOfStream in the second request(this is how envoy operates); content-type is json, buffer", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_ResponseHeaders{ + ResponseHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "content-type", + Value: "application/json", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-sheddable\",\"prompt\":\"test6\",\"temperature\":0}"), EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{Body: []byte(""), EndOfStream: true}, + }, + }, + }, + + // // pod 0 will be picked as all other models are above threshold pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ fakePod(0): { @@ -313,69 +1136,261 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, }, }, - wantHeaders: []*configPb.HeaderValueOption{ + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ { - Header: &configPb.HeaderValue{ - Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("192.168.1.1:8000"), + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + }, + }, + }, + }, }, }, { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte("76"), + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-sheddable\",\"prompt\":\"test6\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, }, }, }, - wantMetadata: makeMetadata("192.168.1.1:8000"), - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), - wantMetrics: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 - `, - wantErr: false, }, - } - - // Set up global k8sclient and extproc server runner with test environment config - cleanup := BeforeSuit(t) - defer cleanup() - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, test.pods) - t.Cleanup(cleanup) - want := &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: test.wantHeaders, + { + name: "responsebody sent over a single request, but empty body with EndOfStream in the second request(this is how envoy operates); content-type is json, buffer", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_ResponseHeaders{ + ResponseHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "content-type", + RawValue: []byte("text/event-stream"), + }, + { + Key: "status", + RawValue: []byte("200"), + }, + }, }, - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_Body{ - Body: test.wantBody, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"NEVER","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GONNA","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GIVE","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"YOU","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"UP","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}}`), + EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte("data: [DONE]"), + EndOfStream: true}, + }, + }, + }, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + }, }, }, }, }, }, - DynamicMetadata: test.wantMetadata, - } - res, err := sendRequest(t, client, test.req) + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"NEVER","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GONNA","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GIVE","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"YOU","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"UP","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + EndOfStream: false, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}}`), + EndOfStream: false, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("data: [DONE]"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, cleanup := setUpHermeticServer(t, test.pods, true) + t.Cleanup(cleanup) + responses, err := streamedRequest(t, client, test.requests, len(test.wantResponses)) if err != nil && !test.wantErr { t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) } - if test.immediateResponse != nil { - want = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: test.immediateResponse, - }, - } - } - if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { + if diff := cmp.Diff(test.wantResponses, responses, protocmp.Transform()); diff != "" { t.Errorf("Unexpected response, (-want +got): %v", diff) } @@ -390,13 +1405,14 @@ func TestKubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { // Reconfigure the TestPodMetricsClient. res := map[types.NamespacedName]*backendmetrics.Metrics{} for pod, metrics := range podAndMetrics { res[pod.NamespacedName] = metrics } serverRunner.TestPodMetricsClient.SetRes(res) + serverRunner.UseStreaming = streamed serverCtx, stopServer := context.WithCancel(context.Background()) @@ -475,7 +1491,7 @@ func fakePod(index int) backendmetrics.Pod { } // Sets up a test environment and returns the runner struct -func BeforeSuit(t *testing.T) func() { +func BeforeSuite() func() { // Set up mock k8s API Client testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, @@ -499,7 +1515,7 @@ func BeforeSuit(t *testing.T) func() { // Init runtime. ctrl.SetLogger(logger) - mgr, err := server.NewDefaultManager("default", "vllm-llama2-7b-pool", cfg) + mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama2-7b-pool")) if err != nil { logutil.Fatal(logger, err, "Failed to create controller manager") } @@ -520,7 +1536,7 @@ func BeforeSuit(t *testing.T) func() { logutil.Fatal(logger, err, "Failed to setup server runner") } - // Start the controller manager in go routine, not blocking + // Start the controller manager in a go routine, not blocking go func() { if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { logutil.Fatal(logger, err, "Failed to start manager") @@ -561,14 +1577,16 @@ func BeforeSuit(t *testing.T) func() { } } - assert.EventuallyWithT(t, func(t *assert.CollectT) { + assert.Eventually(nil, func() bool { modelExist := serverRunner.Datastore.ModelGet("my-model") synced := serverRunner.Datastore.PoolHasSynced() && modelExist != nil - assert.True(t, synced, "Timeout waiting for the pool and models to sync") + return synced }, 10*time.Second, 10*time.Millisecond) return func() { _ = testEnv.Stop() + _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferencePool{}) + _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferenceModel{}) } } @@ -588,6 +1606,44 @@ func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, return res, err } +func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { + for _, req := range requests { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + // Brief pause for the goroutines to execute sequentially and populate the internal pipe channels sequentially + // without the pause there can be a race condition where a goroutine from a subsequent request is able to populate + // the pipe writer channel before a previous chunk. This is simply due to everything running in memory, this would + // not happen in a real world environment with non-zero latency. + time.Sleep(1 * time.Millisecond) + } + responses := []*extProcPb.ProcessingResponse{} + + // Make an incredible simple timeout func in the case where + // there is less than the expected amount of responses; bail and fail. + var simpleTimeout bool + go func() { + time.Sleep(10 * time.Second) + simpleTimeout = true + }() + + for range expectedResponses { + if simpleTimeout { + break + } + res, err := client.Recv() + if err != nil && err != io.EOF { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + responses = append(responses, res) + } + return responses, nil +} + // readDocuments reads documents from file. func readDocuments(fp string) ([][]byte, error) { b, err := os.ReadFile(fp) @@ -658,3 +1714,41 @@ func registerMetricsHandler(mgr manager.Manager, port int) error { } return nil } + +// inject options that allow multiple test runs to run +// https://github.com/kubernetes-sigs/controller-runtime/issues/2937 +func managerTestOptions(namespace, name string) ctrl.Options { + return ctrl.Options{ + Scheme: scheme, + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &corev1.Pod{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + &v1alpha2.InferencePool{}: { + Namespaces: map[string]cache.Config{ + namespace: { + FieldSelector: fields.SelectorFromSet(fields.Set{ + "metadata.name": name, + }), + }, + }, + }, + &v1alpha2.InferenceModel{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + }, + }, + Controller: config.Controller{ + SkipNameValidation: boolPointer(true), + }, + } +} + +func boolPointer(b bool) *bool { + return &b +} diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml index c9ca763e..36b6e539 100644 --- a/test/testdata/inferencepool-with-model-hermetic.yaml +++ b/test/testdata/inferencepool-with-model-hermetic.yaml @@ -50,3 +50,14 @@ spec: targetModels: - name: my-model-12345 weight: 100 +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-direct-model-name + namespace: default +spec: + modelName: direct-model + criticality: Critical + poolRef: + name: vllm-llama2-7b-pool \ No newline at end of file From 98611293268cb3b3e1505b49214cd53f2d9aa338 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Wed, 12 Mar 2025 14:07:47 -0700 Subject: [PATCH 094/260] Add the base model of the cpu vllm sample app to InferenceModel.yaml (#481) --- config/manifests/inferencemodel.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 12fb00b7..8374c5b3 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -21,3 +21,14 @@ spec: criticality: Critical poolRef: name: my-pool + +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-base-model-cpu +spec: + modelName: Qwen/Qwen2.5-1.5B-Instruct + criticality: Critical + poolRef: + name: my-pool From f35114106be38781196041395463a9188214c1f2 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Wed, 12 Mar 2025 16:53:48 -0700 Subject: [PATCH 095/260] Updates docs for k8s sidecar req (#484) Signed-off-by: Daneyon Hansen --- site-src/guides/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 8bcee6e2..94f5c9c1 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -5,8 +5,10 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ## **Prerequisites** - Envoy Gateway [v1.3.0](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - A cluster with: - - Support for services of typs `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). + - Support for services of typs `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). + - Support for [sidecar containers](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/) (enabled by default since Kubernetes v1.29) + to run the model server deployment. ## **Steps** From 75c5737223a5dff115571b4c9e2ea4a915de5587 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 13 Mar 2025 11:15:46 -0400 Subject: [PATCH 096/260] switch default serving and health check ports for bbr (#487) --- cmd/body-based-routing/main.go | 2 +- pkg/body-based-routing/server/runserver.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/body-based-routing/main.go b/cmd/body-based-routing/main.go index 3f586788..66534389 100644 --- a/cmd/body-based-routing/main.go +++ b/cmd/body-based-routing/main.go @@ -40,7 +40,7 @@ var ( "The gRPC port used for communicating with Envoy proxy") grpcHealthPort = flag.Int( "grpcHealthPort", - 9003, + 9005, "The port used for gRPC liveness and readiness probes") logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") diff --git a/pkg/body-based-routing/server/runserver.go b/pkg/body-based-routing/server/runserver.go index 55e79422..90a64b70 100644 --- a/pkg/body-based-routing/server/runserver.go +++ b/pkg/body-based-routing/server/runserver.go @@ -38,7 +38,7 @@ type ExtProcServerRunner struct { // Default values for CLI flags in main const ( - DefaultGrpcPort = 9002 // default for --grpcPort + DefaultGrpcPort = 9004 // default for --grpcPort ) func NewDefaultExtProcServerRunner() *ExtProcServerRunner { From d72819ae3f36b63fb4004f0461bab0beca53c6b1 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Thu, 13 Mar 2025 09:59:48 -0700 Subject: [PATCH 097/260] Fix: e2e test dir and manifest naming (#488) Signed-off-by: Daneyon Hansen --- Makefile | 2 +- test/e2e/epp/e2e_suite_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 40cb0b75..cf053749 100644 --- a/Makefile +++ b/Makefile @@ -127,7 +127,7 @@ test-integration: manifests generate fmt vet envtest ## Run tests. .PHONY: test-e2e test-e2e: ## Run end-to-end tests against an existing Kubernetes cluster with at least 3 available GPUs. - go test ./test/e2e/ -v -ginkgo.v + go test ./test/e2e/epp -v -ginkgo.v .PHONY: lint lint: golangci-lint ## Run golangci-lint linter diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index e7685c48..01da152f 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -57,7 +57,7 @@ const ( // TODO [danehans]: Must be "default" until https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/227 is fixed nsName = "default" // modelServerName is the name of the model server test resources. - modelServerName = "vllm-llama2-7b-pool" + modelServerName = "my-pool" // modelName is the test model name. modelName = "tweet-summary" // envoyName is the name of the envoy proxy test resources. From fb804b06a2a8a8b51758e00466fc5477b3a3e4bb Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 13 Mar 2025 18:59:53 +0000 Subject: [PATCH 098/260] Amend the endpoint picker protocol to support fallbacks and subsetting (#445) * Amend the endpoint picker protocol to support fallbacks and subsetting * Addressed comments * specify the behavior when the epp doesn't respect the subset * addressing more comments * Addressed comments * Addressed comments 2 * typo * clarified that errors must be returned using immediate reponse * updated status code --- .../004-endpoint-picker-protocol/README.md | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/proposals/004-endpoint-picker-protocol/README.md b/docs/proposals/004-endpoint-picker-protocol/README.md index 3657a10e..5280e05c 100644 --- a/docs/proposals/004-endpoint-picker-protocol/README.md +++ b/docs/proposals/004-endpoint-picker-protocol/README.md @@ -9,27 +9,57 @@ This doc defines the protocol between the EPP and the proxy (e.g, Envoy). The EPP MUST implement the Envoy [external processing service](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor) protocol. +## Endpoint Subset +For each HTTP request, the proxy CAN communicate the subset of endpoints the EPP MUST pick from by setting an unstructured entry in the [filter metadata](https://github.com/envoyproxy/go-control-plane/blob/63a55395d7a39a8d43dcc7acc3d05e4cae7eb7a2/envoy/config/core/v3/base.pb.go#L819) field of the ext-proc request. The metadata entry for the subset list MUST be wrapped with an outer key (which represents the metadata namespace) with a default of `envoy.lb.subset_hint`. + +```go +filterMetadata: { + "envoy.lb.subset_hint" { + "x-gateway-destination-endpoint-subset": [, , ...] + } +} +``` + +If the key `x-gateway-destination-endpoint-subset` is set, the EPP MUST only select endpoints from the specified list. If none of the endpoints in the list is eligible or the list is empty, then the EPP MUST return a [ImmediateResponse](https://github.com/envoyproxy/envoy/blob/f2023ef77bdb4abaf9feef963c9a0c291f55568f/api/envoy/service/ext_proc/v3/external_processor.proto#L195) with 503 (Service Unavailable) HTTP status code. If the EPP does not select from the list, then this leads to unpredictable behavior. + +If the key `x-gateway-destination-endpoint-subset` is not set, then the EPP MUST select from the set defined by the `InferencePool` selector. + +## Destination Endpoint For each HTTP request, the EPP MUST communicate to the proxy the picked model server endpoint via: 1. Setting the `x-gateway-destination-endpoint` HTTP header to the selected endpoint in format. 2. Set an unstructured entry in the [dynamic_metadata](https://github.com/envoyproxy/go-control-plane/blob/c19bf63a811c90bf9e02f8e0dc1dcef94931ebb4/envoy/service/ext_proc/v3/external_processor.pb.go#L320) field of the ext-proc response. The metadata entry for the picked endpoint MUST be wrapped with an outer key (which represents the metadata namespace) with a default of `envoy.lb`. -The final metadata necessary would look like: +The primary endpoint MUST be set using the key `x-gateway-destination-endpoint` as follows: ```go dynamicMetadata: { "envoy.lb": { - "x-gateway-destination-endpoint": " + "x-gateway-destination-endpoint": } } ``` -Note: -- If the EPP did not communicate the server endpoint via these two methods, it MUST return an error. +Constraints: +- If the EPP did not communicate the server endpoint via these two methods, it MUST return an error as follows: + - [ImmediateResponse](https://github.com/envoyproxy/envoy/blob/f2023ef77bdb4abaf9feef963c9a0c291f55568f/api/envoy/service/ext_proc/v3/external_processor.proto#L195) with 503 (Serivce Unavailable) HTTP status code if there are no ready endpoints. + - [ImmediateResponse](https://github.com/envoyproxy/envoy/blob/f2023ef77bdb4abaf9feef963c9a0c291f55568f/api/envoy/service/ext_proc/v3/external_processor.proto#L195) with 429 (Too Many Requests) HTTP status code if the request should be dropped (e.g., a Sheddable request, and the servers under heavy load). - The EPP MUST not set two different values in the header and the inner response metadata value. +- Setting different value leads to unpredictable behavior because proxies aren't guaranteed to support both paths, and so this protocol does not define what takes precedence. + +### Destination endpoint fallback +A single fallback endpoint CAN be set using the key `x-gateway-destination-endpoint-fallback` in the same metadata namespace as one used for `x-gateway-destination-endpoint` as follows: -## Why envoy.lb namespace as a default? -The `envoy.lb` namesapce is a predefined namespace used for subsetting. One common way to use the selected endpoint returned from the server, is [envoy subsets](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/subsets) where host metadata for subset load balancing must be placed under `envoy.lb`. +```go +dynamicMetadata: { + "envoy.lb" { + "x-gateway-destination-endpoint-fallback": + } +} +``` -Setting different value leads to unpredictable behavior because proxies aren't guaranteed to support both paths, and so this protocol does not define what takes precedence. +### Why envoy.lb namespace as a default? +The `envoy.lb` namespace is a predefined namespace. One common way to use the selected endpoint returned from the server, is [envoy subsets](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/subsets) where host metadata for subset load balancing must be placed under `envoy.lb`. Note that this is not related to the subsetting feature discussed above, this is an enovy implementation detail. +## Matching An InferenceModel +The model name of a request MUST match the `Spec.ModelName` parameter of one of the `InferenceModels` referencing the `InferencePool` managed by the EPP. Otherwise, the EPP MUST return a 404 status code. From a305d7ae096da31efa9eb8480b54a71c8f157bb7 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 13 Mar 2025 16:29:47 -0400 Subject: [PATCH 099/260] Update Makefile (#490) Add build opts to BBR make script so that "main" tag is added --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index cf053749..c48b9fc7 100644 --- a/Makefile +++ b/Makefile @@ -236,7 +236,7 @@ bbr-image-build: ## Build the image using Docker Buildx. --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ $(PUSH) \ $(LOAD) \ - . + $(BBR_IMAGE_BUILD_EXTRA_OPTS) ./ .PHONY: bbr-image-push bbr-image-push: PUSH=--push ## Build the image and push it to $IMAGE_REPO. From e9f52092eae65ebad8a9993e14cf904653602bbb Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Thu, 13 Mar 2025 16:31:46 -0700 Subject: [PATCH 100/260] Only log errors on response, do not interfere with upstream response message (#494) Signed-off-by: Kellen Swain --- pkg/epp/handlers/server.go | 10 ++++++++-- pkg/epp/handlers/streamingserver.go | 16 +++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index be882fc7..7e8c9e6b 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -117,8 +117,14 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { resp, err = s.HandleResponseHeaders(ctx, reqCtx, req) loggerVerbose.Info("Request context after HandleResponseHeaders", "context", reqCtx) case *extProcPb.ProcessingRequest_ResponseBody: - resp, err = s.HandleResponseBody(ctx, reqCtx, req) - if err == nil && reqCtx.ResponseComplete { + // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. + // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. + // using the standard 'err' var will send an immediate error response back to the caller. + var responseErr error + resp, responseErr = s.HandleResponseBody(ctx, reqCtx, req) + if responseErr != nil { + logger.V(logutil.DEFAULT).Error(responseErr, "Failed to process response body", "request", req) + } else if reqCtx.ResponseComplete { reqCtx.ResponseCompleteTimestamp = time.Now() metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 2aaca7f3..adcd83ed 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -192,15 +192,21 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // Message is buffered, we can read and decode. if v.ResponseBody.EndOfStream { - err = decoder.Decode(&responseBody) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. + // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. + // using the standard 'err' var will send an immediate error response back to the caller. + var responseErr error + responseErr = decoder.Decode(&responseBody) + if responseErr != nil { + logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body") } // Body stream complete. Close the reader pipe. reader.Close() - reqCtx, err = s.HandleResponseBody(ctx, reqCtx, responseBody) - if err == nil && reqCtx.ResponseComplete { + reqCtx, responseErr = s.HandleResponseBody(ctx, reqCtx, responseBody) + if responseErr != nil { + logger.V(logutil.DEFAULT).Error(responseErr, "Failed to process response body", "request", req) + } else if reqCtx.ResponseComplete { reqCtx.ResponseCompleteTimestamp = time.Now() metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) From 2f1c3f99c0bfff0a8ee3fc1c898622c3be86bedf Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Fri, 14 Mar 2025 02:29:47 -0400 Subject: [PATCH 101/260] Add initial set of metrics for BBR (#468) --- cmd/body-based-routing/main.go | 70 ++++++++++++ pkg/body-based-routing/handlers/request.go | 4 + .../handlers/request_test.go | 21 ++++ pkg/body-based-routing/metrics/metrics.go | 103 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 pkg/body-based-routing/metrics/metrics.go diff --git a/cmd/body-based-routing/main.go b/cmd/body-based-routing/main.go index 66534389..13f841b6 100644 --- a/cmd/body-based-routing/main.go +++ b/cmd/body-based-routing/main.go @@ -18,18 +18,26 @@ package main import ( "flag" + "net" + "net/http" "os" + "strconv" "github.com/go-logr/logr" + "github.com/prometheus/client_golang/prometheus/promhttp" uberzap "go.uber.org/zap" "go.uber.org/zap/zapcore" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" + "k8s.io/client-go/rest" + "k8s.io/component-base/metrics/legacyregistry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/server" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -42,6 +50,8 @@ var ( "grpcHealthPort", 9005, "The port used for gRPC liveness and readiness probes") + metricsPort = flag.Int( + "metricsPort", 9090, "The metrics port") logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") setupLog = ctrl.Log.WithName("setup") @@ -95,6 +105,11 @@ func run() error { return err } + // Register metrics handler. + if err := registerMetricsHandler(mgr, *metricsPort, cfg); err != nil { + return err + } + // Start the manager. This blocks until a signal is received. setupLog.Info("Manager starting") if err := mgr.Start(ctx); err != nil { @@ -135,3 +150,58 @@ func initLogging(opts *zap.Options) { logger := zap.New(zap.UseFlagOptions(opts), zap.RawZapOpts(uberzap.AddCaller())) ctrl.SetLogger(logger) } + +const metricsEndpoint = "/metrics" + +// registerMetricsHandler adds the metrics HTTP handler as a Runnable to the given manager. +func registerMetricsHandler(mgr manager.Manager, port int, cfg *rest.Config) error { + metrics.Register() + + // Init HTTP server. + h, err := metricsHandlerWithAuthenticationAndAuthorization(cfg) + if err != nil { + return err + } + + mux := http.NewServeMux() + mux.Handle(metricsEndpoint, h) + + srv := &http.Server{ + Addr: net.JoinHostPort("", strconv.Itoa(port)), + Handler: mux, + } + + if err := mgr.Add(&manager.Server{ + Name: "metrics", + Server: srv, + }); err != nil { + setupLog.Error(err, "Failed to register metrics HTTP handler") + return err + } + return nil +} + +func metricsHandlerWithAuthenticationAndAuthorization(cfg *rest.Config) (http.Handler, error) { + h := promhttp.HandlerFor( + legacyregistry.DefaultGatherer, + promhttp.HandlerOpts{}, + ) + httpClient, err := rest.HTTPClientFor(cfg) + if err != nil { + setupLog.Error(err, "Failed to create http client for metrics auth") + return nil, err + } + + filter, err := filters.WithAuthenticationAndAuthorization(cfg, httpClient) + if err != nil { + setupLog.Error(err, "Failed to create metrics filter for auth") + return nil, err + } + metricsLogger := ctrl.Log.WithName("metrics").WithValues("path", metricsEndpoint) + metricsAuthHandler, err := filter(metricsLogger, h) + if err != nil { + setupLog.Error(err, "Failed to create metrics auth handler") + return nil, err + } + return metricsAuthHandler, nil +} diff --git a/pkg/body-based-routing/handlers/request.go b/pkg/body-based-routing/handlers/request.go index 3c5037a9..6596e191 100644 --- a/pkg/body-based-routing/handlers/request.go +++ b/pkg/body-based-routing/handlers/request.go @@ -24,6 +24,7 @@ import ( basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" eppb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -38,6 +39,7 @@ func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*e modelVal, ok := data["model"] if !ok { + metrics.RecordModelNotInBodyCounter() logger.V(logutil.DEFAULT).Info("Request body does not contain model parameter") return &eppb.ProcessingResponse{ Response: &eppb.ProcessingResponse_RequestBody{ @@ -48,6 +50,7 @@ func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*e modelStr, ok := modelVal.(string) if !ok { + metrics.RecordModelNotParsedCounter() logger.V(logutil.DEFAULT).Info("Model parameter value is not a string") return &eppb.ProcessingResponse{ Response: &eppb.ProcessingResponse_RequestBody{ @@ -56,6 +59,7 @@ func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*e }, fmt.Errorf("the model parameter value %v is not a string", modelVal) } + metrics.RecordSuccessCounter() return &eppb.ProcessingResponse{ Response: &eppb.ProcessingResponse_RequestBody{ RequestBody: &eppb.BodyResponse{ diff --git a/pkg/body-based-routing/handlers/request_test.go b/pkg/body-based-routing/handlers/request_test.go index 9bdac521..76f64e0c 100644 --- a/pkg/body-based-routing/handlers/request_test.go +++ b/pkg/body-based-routing/handlers/request_test.go @@ -18,12 +18,16 @@ package handlers import ( "context" + "strings" "testing" basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" + "k8s.io/component-base/metrics/legacyregistry" + metricsutils "k8s.io/component-base/metrics/testutil" + "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -48,6 +52,7 @@ const ( ) func TestHandleRequestBody(t *testing.T) { + metrics.Register() ctx := logutil.NewTestLoggerIntoContext(context.Background()) tests := []struct { @@ -125,4 +130,20 @@ func TestHandleRequestBody(t *testing.T) { } }) } + + wantMetrics := ` + # HELP bbr_model_not_in_body_total [ALPHA] Count of times the model was not present in the request body. + # TYPE bbr_model_not_in_body_total counter + bbr_model_not_in_body_total{} 1 + # HELP bbr_model_not_parsed_total [ALPHA] Count of times the model was in the request body but we could not parse it. + # TYPE bbr_model_not_parsed_total counter + bbr_model_not_parsed_total{} 1 + # HELP bbr_success_total [ALPHA] Count of successes pulling model name from body and injecting it in the request headers. + # TYPE bbr_success_total counter + bbr_success_total{} 1 + ` + + if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(wantMetrics), "inference_model_request_total"); err != nil { + t.Error(err) + } } diff --git a/pkg/body-based-routing/metrics/metrics.go b/pkg/body-based-routing/metrics/metrics.go new file mode 100644 index 00000000..fc3538fb --- /dev/null +++ b/pkg/body-based-routing/metrics/metrics.go @@ -0,0 +1,103 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "sync" + + compbasemetrics "k8s.io/component-base/metrics" + "k8s.io/component-base/metrics/legacyregistry" +) + +const component = "bbr" + +var ( + successCounter = compbasemetrics.NewCounterVec( + &compbasemetrics.CounterOpts{ + Subsystem: component, + Name: "success_total", + Help: "Count of successes pulling model name from body and injecting it in the request headers.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + modelNotInBodyCounter = compbasemetrics.NewCounterVec( + &compbasemetrics.CounterOpts{ + Subsystem: component, + Name: "model_not_in_body_total", + Help: "Count of times the model was not present in the request body.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + modelNotParsedCounter = compbasemetrics.NewCounterVec( + &compbasemetrics.CounterOpts{ + Subsystem: component, + Name: "model_not_parsed_total", + Help: "Count of times the model was in the request body but we could not parse it.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + + // TODO: Uncomment and use this metrics once the core server implementation has handling to skip body parsing if header exists. + /* + modelAlreadyPresentInHeaderCounter = compbasemetrics.NewCounterVec( + &compbasemetrics.CounterOpts{ + Subsystem: component, + Name: "model_already_present_in_header_total", + Help: "Count of times the model was already present in request headers.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + */ +) + +var registerMetrics sync.Once + +// Register all metrics. +func Register() { + registerMetrics.Do(func() { + legacyregistry.MustRegister(successCounter) + legacyregistry.MustRegister(modelNotInBodyCounter) + legacyregistry.MustRegister(modelNotParsedCounter) + // legacyregistry.MustRegister(modelAlreadyPresentInHeaderCounter) + }) +} + +// RecordSuccessCounter records the number of successful requests to inject the model name into request headers. +func RecordSuccessCounter() { + successCounter.WithLabelValues().Inc() +} + +// RecordModelNotInBodyCounter records the number of times the model was not found in the request body. +func RecordModelNotInBodyCounter() { + modelNotInBodyCounter.WithLabelValues().Inc() +} + +// RecordModelNotParsedCounter records the number of times the model was found in the body but it could not be parsed. +func RecordModelNotParsedCounter() { + modelNotParsedCounter.WithLabelValues().Inc() +} + +/* +// RecordModelAlreadyInHeaderCounter records the number of times the model was already found in the request headers. +func RecordModelAlreadyInHeaderCounter() { + modelAlreadyPresentInHeaderCounter.WithLabelValues().Inc() +} +*/ From 28ea321523ac69519bc52dc19cd986e8272fa0d4 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Fri, 14 Mar 2025 13:13:46 -0400 Subject: [PATCH 102/260] [Metrics] Add streaming support for metrics (#329) Address https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178 --- pkg/epp/handlers/response.go | 109 ++++++++++++++++++++++++++---- pkg/epp/handlers/response_test.go | 45 +++++++++++- pkg/epp/handlers/server.go | 13 +++- site-src/guides/metrics.md | 22 +++--- 4 files changed, 165 insertions(+), 24 deletions(-) diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index f9396acf..44ea6d6a 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -20,9 +20,11 @@ import ( "context" "encoding/json" "fmt" + "strings" configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -67,11 +69,25 @@ func (s *Server) HandleResponseHeaders( // } // } for _, header := range h.ResponseHeaders.Headers.GetHeaders() { + var statusFound, typeFound bool if header.Key == "status" { code := header.RawValue[0] if string(code) != "200" { reqCtx.ResponseStatusCode = errutil.ModelServerError + statusFound = true } + } + if header.Key == "content-type" { + contentType := header.RawValue + if strings.Contains(string(contentType), "text/event-stream") { + reqCtx.Streaming = true + } else { + reqCtx.Streaming = false + } + typeFound = true + } + + if statusFound && typeFound { break } } @@ -132,22 +148,19 @@ func (s *Server) HandleResponseBody( ) (*extProcPb.ProcessingResponse, error) { logger := log.FromContext(ctx) loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Processing HandleResponseBody") body := req.Request.(*extProcPb.ProcessingRequest_ResponseBody) - res := Response{} - if err := json.Unmarshal(body.ResponseBody.Body, &res); err != nil { - return nil, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("unmarshaling response body: %v", err)} + if reqCtx.Streaming { + logger.V(logutil.DEBUG).Info("Processing HandleResponseBody") + if err := s.HandleStreaming(ctx, reqCtx, body, loggerVerbose); err != nil { + return nil, err + } + } else { + loggerVerbose.Info("Processing HandleResponseBody") + if err := s.HandleNonStreaming(ctx, reqCtx, body, loggerVerbose); err != nil { + return nil, err + } } - reqCtx.Response = res - reqCtx.ResponseSize = len(body.ResponseBody.Body) - // ResponseComplete is to indicate the response is complete. In non-streaming - // case, it will be set to be true once the response is processed; in - // streaming case, it will be set to be true once the last chunk is processed. - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) - // will add the processing for streaming case. - reqCtx.ResponseComplete = true - loggerVerbose.Info("Response generated", "response", res) resp := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseBody{ @@ -159,6 +172,76 @@ func (s *Server) HandleResponseBody( return resp, nil } +func (s *Server) HandleNonStreaming( + ctx context.Context, + reqCtx *RequestContext, + body *extProcPb.ProcessingRequest_ResponseBody, + loggerVerbose logr.Logger, +) error { + loggerVerbose.Info("Processing HandleResponseBody") + + res := Response{} + if err := json.Unmarshal(body.ResponseBody.Body, &res); err != nil { + return errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("unmarshaling response body: %v", err)} + } + reqCtx.Response = res + reqCtx.ResponseSize = len(body.ResponseBody.Body) + reqCtx.ResponseComplete = true + loggerVerbose.Info("Response generated", "response", res) + return nil +} + +func (s *Server) HandleStreaming( + ctx context.Context, + reqCtx *RequestContext, + body *extProcPb.ProcessingRequest_ResponseBody, + loggerVerbose logr.Logger, +) error { + respPrefix := "data: " + responseText := string(body.ResponseBody.Body) + // Example message if "stream_options": {"include_usage": "true"} is included in the request: + // data: {"id":"...","object":"text_completion","created":1739400043,"model":"tweet-summary-0","choices":[], + // "usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} + // + // data: [DONE] + // + // Noticed that vLLM returns two entries in one response. + // We need to strip the `data:` prefix and next Data: [DONE] from the message to fetch response data. + // + // If include_usage is not included in the request, `data: [DONE]` is returned separately, which + // indicates end of streaming. + if strings.Contains(responseText, "data: [DONE]") { + response := Response{} + + lines := strings.Split(responseText, "\n") + for _, line := range lines { + if !strings.HasPrefix(line, respPrefix) { + continue + } + content := strings.TrimPrefix(line, respPrefix) + if content == "[DONE]" { + continue + } + + byteSlice := []byte(content) + if err := json.Unmarshal(byteSlice, &response); err != nil { + loggerVerbose.Error(err, "unmarshaling response body") + continue + } + } + reqCtx.Response = response + } + + if body.ResponseBody.EndOfStream { + loggerVerbose.Info("Streaming is completed") + reqCtx.ResponseComplete = true + } else { + reqCtx.ResponseSize += len(body.ResponseBody.Body) + } + + return nil +} + type Response struct { Usage Usage `json:"usage"` } diff --git a/pkg/epp/handlers/response_test.go b/pkg/epp/handlers/response_test.go index 01f02d09..8b6f16a7 100644 --- a/pkg/epp/handlers/response_test.go +++ b/pkg/epp/handlers/response_test.go @@ -49,6 +49,13 @@ const ( } } ` + + streamingBodyWithoutUsage = `data: {"id":"cmpl-41764c93-f9d2-4f31-be08-3ba04fa25394","object":"text_completion","created":1740002445,"model":"tweet-summary-0","choices":[],"usage":null} + ` + + streamingBodyWithUsage = `data: {"id":"cmpl-41764c93-f9d2-4f31-be08-3ba04fa25394","object":"text_completion","created":1740002445,"model":"tweet-summary-0","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} +data: [DONE] + ` ) func TestHandleResponseBody(t *testing.T) { @@ -57,6 +64,7 @@ func TestHandleResponseBody(t *testing.T) { tests := []struct { name string req *extProcPb.ProcessingRequest_ResponseBody + reqCtx *RequestContext want Response wantErr bool }{ @@ -84,12 +92,47 @@ func TestHandleResponseBody(t *testing.T) { }, wantErr: true, }, + { + name: "streaming request without usage", + req: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(streamingBodyWithoutUsage), + }, + }, + reqCtx: &RequestContext{ + Streaming: true, + }, + wantErr: false, + // In the middle of streaming response, so request context response is not set yet. + }, + { + name: "streaming request with usage", + req: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{ + Body: []byte(streamingBodyWithUsage), + }, + }, + reqCtx: &RequestContext{ + Streaming: true, + }, + wantErr: false, + want: Response{ + Usage: Usage{ + PromptTokens: 7, + TotalTokens: 17, + CompletionTokens: 10, + }, + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := &Server{} - reqCtx := &RequestContext{} + reqCtx := test.reqCtx + if reqCtx == nil { + reqCtx = &RequestContext{} + } _, err := server.HandleResponseBody(ctx, reqCtx, &extProcPb.ProcessingRequest{Request: test.req}) if err != nil { if !test.wantErr { diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 7e8c9e6b..4f45ae82 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -131,7 +131,11 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.CompletionTokens) } - loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) + if reqCtx.Streaming { + logger.V(logutil.DEBUG).Info("Request context after HandleResponseBody", "context", reqCtx) + } else { + loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) + } default: logger.V(logutil.DEFAULT).Error(nil, "Unknown Request type", "request", v) return status.Error(codes.Unknown, "unknown request type") @@ -145,7 +149,11 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } - loggerVerbose.Info("Response generated", "response", resp) + if !reqCtx.Streaming { + loggerVerbose.Info("Response generated", "response", resp) + } else { + logger.V(logutil.DEBUG).Info("Response generated", "response", resp) + } if err := srv.Send(resp); err != nil { logger.V(logutil.DEFAULT).Error(err, "Send failed") return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) @@ -220,4 +228,5 @@ type RequestContext struct { ResponseSize int ResponseComplete bool ResponseStatusCode string + Streaming bool } diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index f793734d..a904145d 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -4,14 +4,7 @@ This guide describes the current state of exposed metrics and how to scrape them ## Requirements -Response metrics are only supported in non-streaming mode, with the follow up [issue](https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) to address streaming mode. - -Currently there are two options: -- If requests don't use response streaming, then you can enable `Buffered` mode for response in `EnvoyExtensionPolicy`, this will buffer the response body at the proxy and forward it to the endpoint picker, which allows the endpoint picker to report response metrics. - -- If requests use response streaming, then it is not recommended to enable `Buffered` mode, the response body processing mode should be left empty in the `EnvoyExtensionPolicy` (default). In this case response bodies will not be forwarded to the endpoint picker, and therefore response metrics will not be reported. - - +To have response metrics, set the body mode to `Buffered` or `Streamed`: ``` apiVersion: gateway.envoyproxy.io/v1alpha1 kind: EnvoyExtensionPolicy @@ -32,6 +25,19 @@ spec: body: Buffered ``` +If you want to include usage metrics for vLLM model server streaming request, send the request with `include_usage`: + +``` +curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ +"model": "tweet-summary", +"prompt": "whats your fav movie?", +"max_tokens": 10, +"temperature": 0, +"stream": true, +"stream_options": {"include_usage": "true"} +}' +``` + ## Exposed metrics | **Metric name** | **Metric Type** |
**Description**
|
**Labels**
| **Status** | From a1c95a532a13c778551a1367d444621b9403b9ed Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 14 Mar 2025 20:15:47 +0200 Subject: [PATCH 103/260] added support for testing cpu example in e2e tests (#485) * added support for testing cpu example in e2e tests Signed-off-by: Nir Rozenbaum * minor change in e2e test Signed-off-by: Nir Rozenbaum * fixed linter error Signed-off-by: Nir Rozenbaum * fixed a typo Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- Makefile | 6 ++-- test/e2e/epp/e2e_suite_test.go | 51 ++++++++++++++++++++++++---------- test/e2e/epp/e2e_test.go | 6 +--- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index c48b9fc7..c3c24892 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,8 @@ IMAGE_REGISTRY ?= us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-infe IMAGE_NAME := epp IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(IMAGE_NAME) IMAGE_TAG ?= $(IMAGE_REPO):$(GIT_TAG) +ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +E2E_MANIFEST_PATH ?= config/manifests/vllm/gpu-deployment.yaml SYNCER_IMAGE_NAME := lora-syncer SYNCER_IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(SYNCER_IMAGE_NAME) @@ -126,8 +128,8 @@ test-integration: manifests generate fmt vet envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./test/integration/epp/... -race -coverprofile cover.out .PHONY: test-e2e -test-e2e: ## Run end-to-end tests against an existing Kubernetes cluster with at least 3 available GPUs. - go test ./test/e2e/epp -v -ginkgo.v +test-e2e: ## Run end-to-end tests against an existing Kubernetes cluster. When using default configuration, the tests need at least 3 available GPUs. + MANIFEST_PATH=$(ROOT_DIR)/$(E2E_MANIFEST_PATH) go test ./test/e2e/epp/ -v -ginkgo.v .PHONY: lint lint: golangci-lint ## Run golangci-lint linter diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index 01da152f..bc7dc87a 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -68,8 +68,6 @@ const ( inferExtName = "inference-gateway-ext-proc" // clientManifest is the manifest for the client test resources. clientManifest = "../../testdata/client.yaml" - // modelServerManifest is the manifest for the model server test resources. - modelServerManifest = "../../../config/manifests/vllm/gpu-deployment.yaml" // modelServerSecretManifest is the manifest for the model server secret resource. modelServerSecretManifest = "../../testdata/model-secret.yaml" // inferPoolManifest is the manifest for the inference pool CRD. @@ -80,6 +78,8 @@ const ( inferExtManifest = "../../../config/manifests/ext_proc.yaml" // envoyManifest is the manifest for the envoy proxy test resources. envoyManifest = "../../testdata/envoy.yaml" + // modelServerManifestFilepathEnvVar is the env var that holds absolute path to the manifest for the model server test resource. + modelServerManifestFilepathEnvVar = "MANIFEST_PATH" ) var ( @@ -107,6 +107,7 @@ var _ = ginkgo.BeforeSuite(func() { }) func setupInfra() { + modelServerManifest := readModelServerManifestPath() crds := map[string]string{ "inferencepools.inference.networking.x-k8s.io": inferPoolManifest, "inferencemodels.inference.networking.x-k8s.io": inferModelManifest, @@ -145,6 +146,7 @@ func setupSuite() { kubeCli, err = kubernetes.NewForConfig(cfg) gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(kubeCli).NotTo(gomega.BeNil()) } func cleanupResources() { @@ -181,6 +183,14 @@ func namespaceExists(k8sClient client.Client, ns string) { }, existsTimeout, interval) } +// readModelServerManifestPath reads from env var the absolute filepath to model server deployment for testing. +func readModelServerManifestPath() string { + ginkgo.By(fmt.Sprintf("Ensuring %s environment variable is set", modelServerManifestFilepathEnvVar)) + modelServerManifestFilepath := os.Getenv(modelServerManifestFilepathEnvVar) + gomega.Expect(modelServerManifestFilepath).NotTo(gomega.BeEmpty(), modelServerManifestFilepathEnvVar+" is not set") + return modelServerManifestFilepath +} + // createCRDs creates the Inference Extension CRDs used for testing. func createCRDs(k8sClient client.Client, crds map[string]string) { for name, path := range crds { @@ -215,6 +225,29 @@ func createClient(k8sClient client.Client, filePath string) { // createModelServer creates the model server resources used for testing from the given filePaths. func createModelServer(k8sClient client.Client, secretPath, deployPath string) { + ginkgo.By("Ensuring the model server manifest points to an existing file") + modelServerManifestArray := readYaml(deployPath) + gomega.Expect(modelServerManifestArray).NotTo(gomega.BeEmpty()) + modelServerManifestYaml := modelServerManifestArray[0] + if strings.Contains(modelServerManifestYaml, "hf-token") { + createHfSecret(k8sClient, secretPath) + } + + ginkgo.By("Creating model server resources from manifest: " + deployPath) + createObjsFromYaml(k8sClient, modelServerManifestArray) + + // Wait for the deployment to exist. + deploy := &appsv1.Deployment{} + testutils.EventuallyExists(ctx, func() error { + return k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: modelServerName}, deploy) + }, existsTimeout, interval) + + // Wait for the deployment to be available. + testutils.DeploymentAvailable(ctx, k8sClient, deploy, modelReadyTimeout, interval) +} + +// createHfSecret read HF_TOKEN from env var and creates a secret that contains the access token. +func createHfSecret(k8sClient client.Client, secretPath string) { ginkgo.By("Ensuring the HF_TOKEN environment variable is set") token := os.Getenv("HF_TOKEN") gomega.Expect(token).NotTo(gomega.BeEmpty(), "HF_TOKEN is not set") @@ -226,25 +259,13 @@ func createModelServer(k8sClient client.Client, secretPath, deployPath string) { outManifests = append(outManifests, strings.Replace(m, "$HF_TOKEN", token, 1)) } - ginkgo.By("Creating model server secret resource from manifest: " + deployPath) + ginkgo.By("Creating model server secret resource") createObjsFromYaml(k8sClient, outManifests) // Wait for the secret to exist before proceeding with test. testutils.EventuallyExists(ctx, func() error { return k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: "hf-token"}, &corev1.Secret{}) }, existsTimeout, interval) - - ginkgo.By("Creating model server resources from manifest: " + deployPath) - applyYAMLFile(k8sClient, deployPath) - - // Wait for the deployment to exist. - deploy := &appsv1.Deployment{} - testutils.EventuallyExists(ctx, func() error { - return k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: modelServerName}, deploy) - }, existsTimeout, interval) - - // Wait for the deployment to be available. - testutils.DeploymentAvailable(ctx, k8sClient, deploy, modelReadyTimeout, interval) } // createEnvoy creates the envoy proxy resources used for testing from the given filePath. diff --git a/test/e2e/epp/e2e_test.go b/test/e2e/epp/e2e_test.go index f5cfaf24..09c8835a 100644 --- a/test/e2e/epp/e2e_test.go +++ b/test/e2e/epp/e2e_test.go @@ -49,11 +49,7 @@ var _ = ginkgo.Describe("InferencePool", func() { ginkgo.By("Ensuring the InferenceModel resource exists in the namespace") gomega.Eventually(func() error { - err := cli.Get(ctx, types.NamespacedName{Namespace: infModel.Namespace, Name: infModel.Name}, infModel) - if err != nil { - return err - } - return nil + return cli.Get(ctx, types.NamespacedName{Namespace: infModel.Namespace, Name: infModel.Name}, infModel) }, existsTimeout, interval).Should(gomega.Succeed()) ginkgo.By("Verifying connectivity through the inference extension") From a13179a0bb6f1790dae2bb93d9ac2676b2201628 Mon Sep 17 00:00:00 2001 From: BenjaminBraunDev Date: Fri, 14 Mar 2025 11:41:47 -0700 Subject: [PATCH 104/260] Redesign EPP Metrics Pipeline to be Model Server Agnostic (#461) * start adding metrics changes for trion support * Refactor metrics to work with any prometheus metric naming convention based on EPP runtime flags. * Finalize metric refactor and testing. * Set streaming env var to false in triton ext_proc.yaml * Update titon server deployment to pull frozen repo branch instead of main for consistency. * Remove model server specific metric files and tests and point EPP image to main AR instead of testing registry. * Remove commented prints and old comments. * Remove triton support for now, make metrics mapping 1-to-1 with load balancing metrics. * moved files for cleaner diff * re-add todos and rename kv flag to reflect percentage usage. * Fix nits, move logging channel for backend/metrics.go from default to trace, fix comments. * Rebase into metric agnostic redesign. * Merge getLatestMetric and getLabeledMetric. * Remove unused datastore types. * Fix lint. * Remove log and fix nits. * Move ext_proc and inferencemodel yaml files back, fix nits and remove all logging from metrics.go. * Remove the rest of logging from metrics.go and tests. * Add trace log to podmetrics and small warning fix to metrics_spec_test. --- cmd/epp/main.go | 26 +- pkg/epp/backend/metrics/metrics.go | 245 +++++++++ pkg/epp/backend/metrics/metrics_spec.go | 113 +++++ pkg/epp/backend/metrics/metrics_spec_test.go | 173 +++++++ pkg/epp/backend/metrics/metrics_test.go | 505 +++++++++++++++++++ pkg/epp/backend/metrics/pod_metrics.go | 1 + pkg/epp/backend/vllm/metrics.go | 237 --------- pkg/epp/backend/vllm/metrics_test.go | 250 --------- 8 files changed, 1061 insertions(+), 489 deletions(-) create mode 100644 pkg/epp/backend/metrics/metrics.go create mode 100644 pkg/epp/backend/metrics/metrics_spec.go create mode 100644 pkg/epp/backend/metrics/metrics_spec_test.go create mode 100644 pkg/epp/backend/metrics/metrics_test.go delete mode 100644 pkg/epp/backend/vllm/metrics.go delete mode 100644 pkg/epp/backend/vllm/metrics_test.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index e1cd5015..fa63f0bc 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -38,7 +38,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/vllm" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" @@ -92,6 +91,17 @@ var ( "certPath", "", "The path to the certificate for secure serving. The certificate and private key files "+ "are assumed to be named tls.crt and tls.key, respectively. If not set, and secureServing is enabled, "+ "then a self-signed certificate is used.") + // metric flags + totalQueuedRequestsMetric = flag.String("totalQueuedRequestsMetric", + "vllm:num_requests_waiting", + "Prometheus metric for the number of queued requests.") + kvCacheUsagePercentageMetric = flag.String("kvCacheUsagePercentageMetric", + "vllm:gpu_cache_usage_perc", + "Prometheus metric for the fraction of KV-cache blocks currently in use (from 0 to 1).") + // LoRA metrics + loraInfoMetric = flag.String("loraInfoMetric", + "vllm:lora_requests_info", + "Prometheus metric for the LoRA info metrics (must be in vLLM label format).") setupLog = ctrl.Log.WithName("setup") ) @@ -143,9 +153,21 @@ func run() error { ctx := ctrl.SetupSignalHandler() - pmf := backendmetrics.NewPodMetricsFactory(&vllm.PodMetricsClientImpl{}, *refreshMetricsInterval) + // Set up mapper for metric scraping. + mapping, err := backendmetrics.NewMetricMapping( + *totalQueuedRequestsMetric, + *kvCacheUsagePercentageMetric, + *loraInfoMetric, + ) + if err != nil { + setupLog.Error(err, "Failed to create metric mapping from flags.") + return err + } + + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.PodMetricsClientImpl{MetricMapping: mapping}, *refreshMetricsInterval) // Setup runner. datastore := datastore.NewDatastore(ctx, pmf) + serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, diff --git a/pkg/epp/backend/metrics/metrics.go b/pkg/epp/backend/metrics/metrics.go new file mode 100644 index 00000000..be732e78 --- /dev/null +++ b/pkg/epp/backend/metrics/metrics.go @@ -0,0 +1,245 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "go.uber.org/multierr" +) + +const ( + // LoRA metrics based on protocol + LoraInfoRunningAdaptersMetricName = "running_lora_adapters" + LoraInfoWaitingAdaptersMetricName = "waiting_lora_adapters" + LoraInfoMaxAdaptersMetricName = "max_lora" +) + +type PodMetricsClientImpl struct { + MetricMapping *MetricMapping +} + +// FetchMetrics fetches metrics from a given pod. +func (p *PodMetricsClientImpl) FetchMetrics( + ctx context.Context, + pod *Pod, + existing *Metrics, + port int32, +) (*Metrics, error) { + + // Currently the metrics endpoint is hard-coded, which works with vLLM. + // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. + url := "http://" + pod.Address + ":" + strconv.Itoa(int(port)) + "/metrics" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod.NamespacedName, err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code from %s: %v", pod.NamespacedName, resp.StatusCode) + } + + parser := expfmt.TextParser{} + metricFamilies, err := parser.TextToMetricFamilies(resp.Body) + if err != nil { + return nil, err + } + return p.promToPodMetrics(metricFamilies, existing) +} + +// promToPodMetrics updates internal pod metrics with scraped Prometheus metrics. +func (p *PodMetricsClientImpl) promToPodMetrics( + metricFamilies map[string]*dto.MetricFamily, + existing *Metrics, +) (*Metrics, error) { + var errs error + updated := existing.Clone() + + if p.MetricMapping.TotalQueuedRequests != nil { + queued, err := p.getMetric(metricFamilies, *p.MetricMapping.TotalQueuedRequests) + if err == nil { + updated.WaitingQueueSize = int(queued.GetGauge().GetValue()) + } else { + errs = multierr.Append(errs, err) + } + } + + if p.MetricMapping.KVCacheUtilization != nil { + usage, err := p.getMetric(metricFamilies, *p.MetricMapping.KVCacheUtilization) + if err == nil { + updated.KVCacheUsagePercent = usage.GetGauge().GetValue() + } else { + errs = multierr.Append(errs, err) + } + } + + // Handle LoRA metrics (only if all LoRA MetricSpecs are present) + if p.MetricMapping.LoraRequestInfo != nil { + loraMetrics, err := p.getLatestLoraMetric(metricFamilies) + errs = multierr.Append(errs, err) + + if loraMetrics != nil { + updated.ActiveModels = make(map[string]int) + for _, label := range loraMetrics.GetLabel() { + if label.GetName() == LoraInfoRunningAdaptersMetricName { + if label.GetValue() != "" { + adapterList := strings.Split(label.GetValue(), ",") + for _, adapter := range adapterList { + updated.ActiveModels[adapter] = 0 + } + } + } + if label.GetName() == LoraInfoWaitingAdaptersMetricName { + if label.GetValue() != "" { + adapterList := strings.Split(label.GetValue(), ",") + for _, adapter := range adapterList { + updated.ActiveModels[adapter] = 0 + } + } + } + if label.GetName() == LoraInfoMaxAdaptersMetricName { + if label.GetValue() != "" { + updated.MaxActiveModels, err = strconv.Atoi(label.GetValue()) + if err != nil { + errs = multierr.Append(errs, err) + } + } + } + } + } + } + + return updated, errs +} + +// getLatestLoraMetric gets latest lora metric series in gauge metric family `vllm:lora_requests_info` +// reason its specially fetched is because each label key value pair permutation generates new series +// and only most recent is useful. The value of each series is the creation timestamp so we can +// retrieve the latest by sorting the value. +func (p *PodMetricsClientImpl) getLatestLoraMetric(metricFamilies map[string]*dto.MetricFamily) (*dto.Metric, error) { + if p.MetricMapping.LoraRequestInfo == nil { + return nil, nil // No LoRA metrics configured + } + + loraRequests, ok := metricFamilies[p.MetricMapping.LoraRequestInfo.MetricName] + if !ok { + return nil, fmt.Errorf("metric family %q not found", p.MetricMapping.LoraRequestInfo.MetricName) + } + + var latest *dto.Metric + var latestTs float64 // Use float64, as Gauge.Value is float64 + + // Iterate over all metrics in the family. + for _, m := range loraRequests.GetMetric() { + running := "" + waiting := "" + // Check if the metric has the expected LoRA labels. + for _, lp := range m.GetLabel() { + switch lp.GetName() { + case LoraInfoRunningAdaptersMetricName: + running = lp.GetValue() + case LoraInfoWaitingAdaptersMetricName: + waiting = lp.GetValue() + } + } + // Ignore metrics with both labels empty. + if running == "" && waiting == "" { + continue + } + + // Select the metric with the *largest Gauge Value* (which represents the timestamp). + if m.GetGauge().GetValue() > latestTs { + latestTs = m.GetGauge().GetValue() + latest = m + } + } + if latest == nil { + return nil, nil + } + + return latest, nil // Convert nanoseconds to time.Time +} + +// getMetric retrieves a specific metric based on MetricSpec. +func (p *PodMetricsClientImpl) getMetric(metricFamilies map[string]*dto.MetricFamily, spec MetricSpec) (*dto.Metric, error) { + mf, ok := metricFamilies[spec.MetricName] + if !ok { + return nil, fmt.Errorf("metric family %q not found", spec.MetricName) + } + + if len(mf.GetMetric()) == 0 { + return nil, fmt.Errorf("no metrics available for %q", spec.MetricName) + } + + return getLatestMetric(mf, &spec) +} + +// getLabeledMetric gets the latest metric with matching labels. +func getLatestMetric(mf *dto.MetricFamily, spec *MetricSpec) (*dto.Metric, error) { + var latestMetric *dto.Metric + var latestTimestamp int64 = -1 // Initialize to -1 so any timestamp is greater + + for _, m := range mf.GetMetric() { + if spec.Labels == nil || labelsMatch(m.GetLabel(), spec.Labels) { + if m.GetTimestampMs() > latestTimestamp { + latestTimestamp = m.GetTimestampMs() + latestMetric = m + } + } + } + + if latestMetric != nil { + return latestMetric, nil + } + + return nil, fmt.Errorf("no matching metric found for %q with labels %+v", spec.MetricName, spec.Labels) +} + +// labelsMatch checks if a metric's labels contain all the labels in the spec. +func labelsMatch(metricLabels []*dto.LabelPair, specLabels map[string]string) bool { + if len(specLabels) == 0 { + return true // No specific labels required + } + + for specName, specValue := range specLabels { + found := false + for _, label := range metricLabels { + if label.GetName() == specName && label.GetValue() == specValue { + found = true + break + } + } + if !found { + return false // A required label is missing + } + } + return true // All required labels are present +} diff --git a/pkg/epp/backend/metrics/metrics_spec.go b/pkg/epp/backend/metrics/metrics_spec.go new file mode 100644 index 00000000..ce0c075d --- /dev/null +++ b/pkg/epp/backend/metrics/metrics_spec.go @@ -0,0 +1,113 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "fmt" + "strings" +) + +// MetricSpec represents a single metric's specification. +type MetricSpec struct { + MetricName string + Labels map[string]string // Label name -> Label value +} + +// MetricMapping holds named MetricSpecs. +type MetricMapping struct { + TotalQueuedRequests *MetricSpec + KVCacheUtilization *MetricSpec + LoraRequestInfo *MetricSpec +} + +// stringToMetricSpec converts a string to a MetricSpec. +// Example inputs: +// +// "metric_name" +// "metric_name{label1=value1}" +// "metric_name{label1=value1,label2=value2}" +func stringToMetricSpec(specStr string) (*MetricSpec, error) { + specStr = strings.TrimSpace(specStr) + metricName := specStr + labels := make(map[string]string) + + // Check for labels enclosed in curly braces + start := strings.Index(specStr, "{") + end := strings.Index(specStr, "}") + + if start != -1 || end != -1 { // If *either* brace is present... + if start == -1 || end == -1 || end <= start+1 { // ...check that *both* are present and correctly placed. + return nil, fmt.Errorf("invalid metric spec string: %q, missing or malformed label block", specStr) + } + + metricName = strings.TrimSpace(specStr[:start]) + labelStr := specStr[start+1 : end] + + // Split into individual label pairs + labelPairs := strings.Split(labelStr, ",") + for _, pair := range labelPairs { + pair = strings.TrimSpace(pair) + parts := strings.Split(pair, "=") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid label pair: %q in metric spec: %q", pair, specStr) + } + labelName := strings.TrimSpace(parts[0]) + labelValue := strings.TrimSpace(parts[1]) + if labelName == "" || labelValue == "" { + return nil, fmt.Errorf("empty label name or value in pair: %q in metric spec: %q", pair, specStr) + } + labels[labelName] = labelValue + } + // Check for extra characters after labels + if end != len(specStr)-1 { + return nil, fmt.Errorf("invalid characters after label section in: %q", specStr) + } + + } + + if metricName == "" { // Metric name cannot be empty + return nil, fmt.Errorf("empty metric name in spec: %q", specStr) + } + + return &MetricSpec{ + MetricName: metricName, + Labels: labels, + }, nil +} + +// NewMetricMapping creates a MetricMapping from string values. +func NewMetricMapping(queuedStr, kvUsageStr, loraReqInfoStr string) (*MetricMapping, error) { + queuedSpec, err := stringToMetricSpec(queuedStr) + if err != nil { + return nil, fmt.Errorf("error parsing WaitingRequests: %w", err) + } + kvUsageSpec, err := stringToMetricSpec(kvUsageStr) + if err != nil { + return nil, fmt.Errorf("error parsing KVCacheUsage: %w", err) + } + loraReqInfoSpec, err := stringToMetricSpec(loraReqInfoStr) + if err != nil { + return nil, fmt.Errorf("error parsing loraReqInfoStr: %w", err) + } + mapping := &MetricMapping{ + TotalQueuedRequests: queuedSpec, + KVCacheUtilization: kvUsageSpec, + LoraRequestInfo: loraReqInfoSpec, + } + + return mapping, nil +} diff --git a/pkg/epp/backend/metrics/metrics_spec_test.go b/pkg/epp/backend/metrics/metrics_spec_test.go new file mode 100644 index 00000000..82804206 --- /dev/null +++ b/pkg/epp/backend/metrics/metrics_spec_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "reflect" + "testing" +) + +func TestStringToMetricSpec(t *testing.T) { + tests := []struct { + name string + input string + want *MetricSpec + wantErr bool + }{ + { + name: "empty string", + input: "", + want: nil, + wantErr: true, + }, + { + name: "no labels", + input: "my_metric", + want: &MetricSpec{ + MetricName: "my_metric", + Labels: map[string]string{}, + }, + wantErr: false, + }, + { + name: "one label", + input: "my_metric{label1=value1}", + want: &MetricSpec{ + MetricName: "my_metric", + Labels: map[string]string{ + "label1": "value1", + }, + }, + wantErr: false, + }, + { + name: "multiple labels", + input: "my_metric{label1=value1,label2=value2}", + want: &MetricSpec{ + MetricName: "my_metric", + Labels: map[string]string{ + "label1": "value1", + "label2": "value2", + }, + }, + wantErr: false, + }, + { + name: "extra whitespace", + input: " my_metric { label1 = value1 , label2 = value2 } ", + want: &MetricSpec{ + MetricName: "my_metric", + Labels: map[string]string{ + "label1": "value1", + "label2": "value2", + }, + }, + wantErr: false, + }, + { + name: "missing closing brace", + input: "my_metric{label1=value1", + want: nil, + wantErr: true, + }, + { + name: "missing opening brace", + input: "my_metriclabel1=value1}", + want: nil, // Corrected expected value + wantErr: true, + }, + { + name: "invalid label pair", + input: "my_metric{label1}", + want: nil, + wantErr: true, + }, + { + name: "empty label name", + input: "my_metric{=value1}", + want: nil, + wantErr: true, + }, + { + name: "empty label value", + input: "my_metric{label1=}", + want: nil, + wantErr: true, + }, + { + name: "empty label name and value with spaces", + input: "my_metric{ = }", + want: nil, + wantErr: true, + }, + { + name: "characters after closing brace", + input: "my_metric{label=val}extra", + want: nil, + wantErr: true, + }, + { + name: "empty metric name", + input: "{label=val}", + want: nil, + wantErr: true, + }, + { + name: "no labels and just metric name with space", + input: "my_metric ", + want: &MetricSpec{ + MetricName: "my_metric", + Labels: map[string]string{}, + }, + wantErr: false, + }, + { + name: "no labels and just metric name with space before and after", + input: " my_metric ", + want: &MetricSpec{ + MetricName: "my_metric", + Labels: map[string]string{}, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := stringToMetricSpec(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("stringToMetricSpec() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if got != nil { // handles if we got a nil spec and didn't expect an error + t.Errorf("stringToMetricSpec() = %v, want %v", got, tt.want) + return + } + } else { + if got == nil { + t.Fatalf("stringToMetricSpec() = got nil but wanted %v", tt.want) + } + if !reflect.DeepEqual(got.MetricName, tt.want.MetricName) { + t.Errorf("stringToMetricSpec() got MetricName = %v, want %v", got.MetricName, tt.want.MetricName) + } + if !reflect.DeepEqual(got.Labels, tt.want.Labels) { + t.Errorf("stringToMetricSpec() got Labels = %v, want %v", got.Labels, tt.want.Labels) + } + } + }) + } +} diff --git a/pkg/epp/backend/metrics/metrics_test.go b/pkg/epp/backend/metrics/metrics_test.go new file mode 100644 index 00000000..d0396bf7 --- /dev/null +++ b/pkg/epp/backend/metrics/metrics_test.go @@ -0,0 +1,505 @@ +/* +Copyright 2025 The Kubernetes 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 metrics + +import ( + "context" + "errors" + "reflect" + "strconv" + "strings" + "testing" + + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/assert" + "go.uber.org/multierr" + "google.golang.org/protobuf/proto" + "k8s.io/apimachinery/pkg/types" + + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// --- Test Helpers --- + +func makeMetric(labels map[string]string, value float64, timestampMs int64) *dto.Metric { + labelPairs := []*dto.LabelPair{} + for k, v := range labels { + labelPairs = append(labelPairs, &dto.LabelPair{Name: proto.String(k), Value: proto.String(v)}) + } + return &dto.Metric{ + Label: labelPairs, + Gauge: &dto.Gauge{Value: &value}, + TimestampMs: ×tampMs, + } +} + +func makeMetricFamily(name string, metrics ...*dto.Metric) *dto.MetricFamily { + return &dto.MetricFamily{ + Name: &name, + Type: dto.MetricType_GAUGE.Enum(), + Metric: metrics, + } +} + +// --- Tests --- + +func TestGetMetric(t *testing.T) { + + metricFamilies := map[string]*dto.MetricFamily{ + "metric1": makeMetricFamily("metric1", + makeMetric(map[string]string{"label1": "value1"}, 1.0, 1000), + makeMetric(map[string]string{"label1": "value2"}, 2.0, 2000), + ), + "metric2": makeMetricFamily("metric2", + makeMetric(map[string]string{"labelA": "A1", "labelB": "B1"}, 3.0, 1500), + makeMetric(map[string]string{"labelA": "A2", "labelB": "B2"}, 4.0, 2500), + ), + "metric3": makeMetricFamily("metric3", + makeMetric(map[string]string{}, 5.0, 3000), + makeMetric(map[string]string{}, 6.0, 1000), + ), + } + + tests := []struct { + name string + spec MetricSpec + wantGaugeValue float64 + wantError bool + }{ + { + name: "get labeled metric, exists", + spec: MetricSpec{ + MetricName: "metric1", + Labels: map[string]string{"label1": "value1"}, + }, + wantGaugeValue: 1.0, + wantError: false, + }, + { + name: "get labeled metric, wrong value", + spec: MetricSpec{ + MetricName: "metric1", + Labels: map[string]string{"label1": "value3"}, + }, + wantGaugeValue: -1, // Expect an error, not a specific value + wantError: true, + }, + { + name: "get labeled metric, missing label", + spec: MetricSpec{ + MetricName: "metric1", + Labels: map[string]string{"label2": "value2"}, + }, + wantGaugeValue: -1, + wantError: true, + }, + { + name: "get labeled metric, extra label present", + spec: MetricSpec{ + MetricName: "metric2", + Labels: map[string]string{"labelA": "A1"}, + }, + wantGaugeValue: 3.0, + wantError: false, + }, + { + name: "get unlabeled metric, exists", + spec: MetricSpec{ + MetricName: "metric3", + Labels: nil, // Explicitly nil + }, + wantGaugeValue: 5.0, // latest metric, which occurs first in our test data + wantError: false, + }, + { + name: "get unlabeled metric, metric family not found", + spec: MetricSpec{ + MetricName: "metric4", + Labels: nil, + }, + wantGaugeValue: -1, + wantError: true, + }, + { + name: "get labeled metric, metric family not found", + spec: MetricSpec{ + MetricName: "metric4", + Labels: map[string]string{"label1": "value1"}, + }, + wantGaugeValue: -1, + wantError: true, + }, + { + name: "get metric, no metrics available", + spec: MetricSpec{ + MetricName: "empty_metric", + }, + wantGaugeValue: -1, + wantError: true, + }, + { + name: "get latest metric", + spec: MetricSpec{ + MetricName: "metric3", + Labels: map[string]string{}, // Empty map, not nil + }, + wantGaugeValue: 5.0, + wantError: false, + }, + } + + p := &PodMetricsClientImpl{} // No need for MetricMapping here + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + gotMetric, err := p.getMetric(metricFamilies, tt.spec) + + if tt.wantError { + if err == nil { + t.Errorf("getMetric() expected error, got nil") + } + } else { + if err != nil { + t.Fatalf("getMetric() unexpected error: %v", err) + } + if gotMetric.GetGauge().GetValue() != tt.wantGaugeValue { + t.Errorf("getMetric() got value %v, want %v", gotMetric.GetGauge().GetValue(), tt.wantGaugeValue) + } + } + }) + } +} + +func TestLabelsMatch(t *testing.T) { + tests := []struct { + name string + metricLabels []*dto.LabelPair + specLabels map[string]string + want bool + }{ + { + name: "empty spec labels, should match", + metricLabels: []*dto.LabelPair{{Name: proto.String("a"), Value: proto.String("b")}}, + specLabels: map[string]string{}, + want: true, + }, + { + name: "nil spec labels, should match", + metricLabels: []*dto.LabelPair{{Name: proto.String("a"), Value: proto.String("b")}}, + specLabels: nil, + want: true, + }, + { + name: "exact match", + metricLabels: []*dto.LabelPair{{Name: proto.String("a"), Value: proto.String("b")}}, + specLabels: map[string]string{"a": "b"}, + want: true, + }, + { + name: "extra labels in metric", + metricLabels: []*dto.LabelPair{{Name: proto.String("a"), Value: proto.String("b")}, {Name: proto.String("c"), Value: proto.String("d")}}, + specLabels: map[string]string{"a": "b"}, + want: true, + }, + { + name: "missing label in metric", + metricLabels: []*dto.LabelPair{{Name: proto.String("a"), Value: proto.String("b")}}, + specLabels: map[string]string{"a": "b", "c": "d"}, + want: false, + }, + { + name: "value mismatch", + metricLabels: []*dto.LabelPair{{Name: proto.String("a"), Value: proto.String("b")}}, + specLabels: map[string]string{"a": "c"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := labelsMatch(tt.metricLabels, tt.specLabels); got != tt.want { + t.Errorf("labelsMatch() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetLatestLoraMetric(t *testing.T) { + + testCases := []struct { + name string + metricFamilies map[string]*dto.MetricFamily + expectedAdapters map[string]int + expectedMax int + expectedErr error + mapping *MetricMapping + }{ + { + name: "no lora metrics", + metricFamilies: map[string]*dto.MetricFamily{ + "some_other_metric": makeMetricFamily("some_other_metric", + makeMetric(nil, 1.0, 1000), + ), + }, + expectedAdapters: nil, + expectedMax: 0, + expectedErr: errors.New("metric family \"vllm:lora_requests_info\" not found"), // Expect an error because the family is missing + mapping: &MetricMapping{ + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + }, + { + name: "basic lora metrics", + metricFamilies: map[string]*dto.MetricFamily{ + "vllm:lora_requests_info": makeMetricFamily("vllm:lora_requests_info", + makeMetric(map[string]string{"running_lora_adapters": "lora1", "max_lora": "2"}, 3000.0, 1000), // Newer + makeMetric(map[string]string{"running_lora_adapters": "lora2,lora3", "max_lora": "4"}, 1000.0, 1000), // Older + + ), + }, + expectedAdapters: map[string]int{"lora1": 0}, + expectedMax: 2, + expectedErr: nil, + mapping: &MetricMapping{ + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + }, + { + name: "no matching lora metrics", + metricFamilies: map[string]*dto.MetricFamily{ + "vllm:lora_requests_info": makeMetricFamily("vllm:lora_requests_info", + makeMetric(map[string]string{"other_label": "value"}, 5.0, 3000), + ), + }, + expectedAdapters: nil, + expectedMax: 0, + expectedErr: nil, // Expect *no* error; just no adapters found + mapping: &MetricMapping{ + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + }, + { + name: "no lora metrics if not in MetricMapping", + metricFamilies: map[string]*dto.MetricFamily{ + "vllm:lora_requests_info": makeMetricFamily("vllm:lora_requests_info", + makeMetric(map[string]string{"running_lora_adapters": "lora1", "max_lora": "2"}, 5.0, 3000), + makeMetric(map[string]string{"running_lora_adapters": "lora2,lora3", "max_lora": "4"}, 6.0, 1000), + ), + }, + expectedAdapters: nil, + expectedMax: 0, + expectedErr: nil, + mapping: &MetricMapping{ // No LoRA metrics defined + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := &PodMetricsClientImpl{MetricMapping: tc.mapping} + loraMetric, err := p.getLatestLoraMetric(tc.metricFamilies) + + if tc.expectedErr != nil { + if err == nil || err.Error() != tc.expectedErr.Error() { + t.Errorf("getLatestLoraMetric() error = %v, wantErr %v", err, tc.expectedErr) + } + return // Stop here if an error was expected + } else if err != nil { + t.Fatalf("getLatestLoraMetric() unexpected error: %v", err) + } + + if tc.mapping.LoraRequestInfo == nil { + if loraMetric != nil { + t.Errorf("getLatestLoraMetric() expected nil metric, got %v", loraMetric) + } + return // Stop if no Lora metrics are expected. + } + + if tc.expectedAdapters == nil && loraMetric == nil { + return // Both nil, as expected + } + + if tc.expectedAdapters != nil && loraMetric != nil { // proceed with checks + + adaptersFound := make(map[string]int) + maxLora := 0 + for _, label := range loraMetric.GetLabel() { + if label.GetName() == "running_lora_adapters" && label.GetValue() != "" { + for _, adapter := range strings.Split(label.GetValue(), ",") { + adaptersFound[adapter] = 0 + } + } + if label.GetName() == "waiting_lora_adapters" && label.GetValue() != "" { + for _, adapter := range strings.Split(label.GetValue(), ",") { + adaptersFound[adapter] = 0 // Overwrite if already present + } + } + if label.GetName() == "max_lora" { + var converr error // define err in this scope. + maxLora, converr = strconv.Atoi(label.GetValue()) + if converr != nil && tc.expectedErr == nil { // only report if we don't expect any other errors + t.Errorf("getLatestLoraMetric() could not parse max_lora: %v", converr) + } + } + } + + if !reflect.DeepEqual(adaptersFound, tc.expectedAdapters) { + t.Errorf("getLatestLoraMetric() adapters = %v, want %v", adaptersFound, tc.expectedAdapters) + } + if maxLora != tc.expectedMax { + t.Errorf("getLatestLoraMetric() maxLora = %v, want %v", maxLora, tc.expectedMax) + } + } else { // one is nil and the other is not + t.Errorf("getLatestLoraMetric(): one of expectedAdapters/loraMetric is nil and the other is not, expected %v, got %v", tc.expectedAdapters, loraMetric) + } + }) + } +} + +func TestPromToPodMetrics(t *testing.T) { + tests := []struct { + name string + metricFamilies map[string]*dto.MetricFamily + mapping *MetricMapping + existingMetrics *Metrics + expectedMetrics *Metrics + expectedErr error // Count of expected errors + }{ + { + name: "vllm metrics", + metricFamilies: map[string]*dto.MetricFamily{ + "vllm_waiting": makeMetricFamily("vllm_waiting", + makeMetric(nil, 5.0, 1000), + makeMetric(nil, 7.0, 2000), // Newer + ), + "vllm_usage": makeMetricFamily("vllm_usage", + makeMetric(nil, 0.8, 2000), + makeMetric(nil, 0.7, 500), + ), + "vllm:lora_requests_info": makeMetricFamily("vllm:lora_requests_info", + makeMetric(map[string]string{"running_lora_adapters": "lora1,lora2", "waiting_lora_adapters": "lora3", "max_lora": "3"}, 3000.0, 1000), + ), + }, + mapping: &MetricMapping{ + TotalQueuedRequests: &MetricSpec{MetricName: "vllm_waiting"}, + KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + existingMetrics: &Metrics{}, + expectedMetrics: &Metrics{ + WaitingQueueSize: 7, + KVCacheUsagePercent: 0.8, + ActiveModels: map[string]int{"lora1": 0, "lora2": 0, "lora3": 0}, + MaxActiveModels: 3, + }, + }, + { + name: "missing metrics", + metricFamilies: map[string]*dto.MetricFamily{}, // No metrics + mapping: &MetricMapping{ + TotalQueuedRequests: &MetricSpec{MetricName: "vllm_waiting"}, + KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + existingMetrics: &Metrics{ActiveModels: map[string]int{}}, + expectedMetrics: &Metrics{ActiveModels: map[string]int{}}, + expectedErr: multierr.Combine(errors.New("metric family \"vllm_waiting\" not found"), errors.New("metric family \"vllm_usage\" not found"), errors.New("metric family \"vllm:lora_requests_info\" not found")), + }, + { + name: "partial metrics available + LoRA", + metricFamilies: map[string]*dto.MetricFamily{ + "vllm_usage": makeMetricFamily("vllm_usage", + makeMetric(nil, 0.8, 2000), // Only usage is present + ), + "vllm:lora_requests_info": makeMetricFamily("vllm:lora_requests_info", + makeMetric(map[string]string{"running_lora_adapters": "lora1,lora2", "waiting_lora_adapters": "lora3", "max_lora": "3"}, 3000.0, 1000), + ), + }, + mapping: &MetricMapping{ + TotalQueuedRequests: &MetricSpec{MetricName: "vllm_waiting"}, // Not Present + KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + existingMetrics: &Metrics{}, + expectedMetrics: &Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.8, + ActiveModels: map[string]int{"lora1": 0, "lora2": 0, "lora3": 0}, + MaxActiveModels: 3, + }, + expectedErr: errors.New("metric family \"vllm_waiting\" not found"), + }, + { + name: "invalid max lora", + metricFamilies: map[string]*dto.MetricFamily{ + "vllm:lora_requests_info": makeMetricFamily("vllm:lora_requests_info", + makeMetric(map[string]string{"running_lora_adapters": "lora1", "max_lora": "invalid"}, 3000.0, 1000), + ), + }, + mapping: &MetricMapping{ + LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, + }, + existingMetrics: &Metrics{}, + expectedMetrics: &Metrics{ + ActiveModels: map[string]int{"lora1": 0}, + MaxActiveModels: 0, // Should still default to 0. + + }, + expectedErr: errors.New("strconv.Atoi: parsing \"invalid\": invalid syntax"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &PodMetricsClientImpl{MetricMapping: tc.mapping} + updated, err := p.promToPodMetrics(tc.metricFamilies, tc.existingMetrics) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.EqualError(t, err, tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedMetrics, updated) + } + }) + } +} + +// TestFetchMetrics is a basic integration test. It assumes +// there's no server running on the specified port. +func TestFetchMetrics(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + pod := &Pod{ + Address: "127.0.0.1", + NamespacedName: types.NamespacedName{ + Namespace: "test", + Name: "pod", + }, + } + existing := &Metrics{} + p := &PodMetricsClientImpl{} // No MetricMapping needed for this basic test + + _, err := p.FetchMetrics(ctx, pod, existing, 9999) // Use a port that's unlikely to be in use. + if err == nil { + t.Errorf("FetchMetrics() expected error, got nil") + } + // Check for a specific error message (fragile, but OK for this example) + expectedSubstr := "connection refused" + if err != nil && !strings.Contains(err.Error(), expectedSubstr) { + t.Errorf("FetchMetrics() error = %v, want error containing %q", err, expectedSubstr) + } +} diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index b954a98c..01db14be 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -115,6 +115,7 @@ func (pm *podMetrics) refreshMetrics() error { defer cancel() updated, err := pm.pmc.FetchMetrics(ctx, pm.GetPod(), pm.GetMetrics(), pool.Spec.TargetPortNumber) if err != nil { + pm.logger.V(logutil.TRACE).Info("Failed to refreshed metrics:", "err", err) // As refresher is running in the background, it's possible that the pod is deleted but // the refresh goroutine doesn't read the done channel yet. In this case, we just return nil. // The refresher will be stopped after this interval. diff --git a/pkg/epp/backend/vllm/metrics.go b/pkg/epp/backend/vllm/metrics.go deleted file mode 100644 index 8d2dd715..00000000 --- a/pkg/epp/backend/vllm/metrics.go +++ /dev/null @@ -1,237 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 vllm provides vllm specific pod metrics implementation. -package vllm - -import ( - "context" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/go-logr/logr" - dto "github.com/prometheus/client_model/go" - "github.com/prometheus/common/expfmt" - "go.uber.org/multierr" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -// Metric names used in the vLLM metrics implementation. -// Refer to the protocol doc for more details: -// https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol -const ( - LoraRequestInfoMetricName = "vllm:lora_requests_info" - LoraRequestInfoRunningAdaptersMetricName = "running_lora_adapters" - LoraRequestInfoWaitingAdaptersMetricName = "waiting_lora_adapters" - LoraRequestInfoMaxAdaptersMetricName = "max_lora" - // TODO: Replace these with the num_tokens_running/waiting below once we add those to the fork. - RunningQueueSizeMetricName = "vllm:num_requests_running" - WaitingQueueSizeMetricName = "vllm:num_requests_waiting" - /* TODO: Uncomment this once the following are added to the fork. - RunningQueueSizeMetricName = "vllm:num_tokens_running" - WaitingQueueSizeMetricName = "vllm:num_tokens_waiting" - */ - KVCacheUsagePercentMetricName = "vllm:gpu_cache_usage_perc" -) - -type PodMetricsClientImpl struct{} - -// FetchMetrics fetches metrics from a given pod. -func (p *PodMetricsClientImpl) FetchMetrics( - ctx context.Context, - pod *metrics.Pod, - existing *metrics.Metrics, - port int32, -) (*metrics.Metrics, error) { - logger := log.FromContext(ctx).V(logutil.TRACE) - - // Currently the metrics endpoint is hard-coded, which works with vLLM. - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. - url := "http://" + pod.Address + ":" + strconv.Itoa(int(port)) + "/metrics" - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - logger.Error(err, "Failed create HTTP request", "method", http.MethodGet, "url", url) - return nil, fmt.Errorf("failed to create request: %v", err) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - logger.Error(err, "Failed to fetch metrics", "pod", pod.NamespacedName) - return nil, fmt.Errorf("failed to fetch metrics from %s: %w", pod.NamespacedName, err) - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - logger.Error(nil, "Unexpected status code returned", "pod", pod.NamespacedName, "statusCode", resp.StatusCode) - return nil, fmt.Errorf("unexpected status code from %s: %v", pod.NamespacedName, resp.StatusCode) - } - - parser := expfmt.TextParser{} - metricFamilies, err := parser.TextToMetricFamilies(resp.Body) - if err != nil { - return nil, err - } - return promToPodMetrics(logger, metricFamilies, existing) -} - -// promToPodMetrics updates internal pod metrics with scraped prometheus metrics. -// A combined error is returned if errors occur in one or more metric processing. -// it returns a new PodMetrics pointer which can be used to atomically update the pod metrics map. -func promToPodMetrics( - logger logr.Logger, - metricFamilies map[string]*dto.MetricFamily, - existing *metrics.Metrics, -) (*metrics.Metrics, error) { - var errs error - updated := existing.Clone() - runningQueueSize, err := getLatestMetric(logger, metricFamilies, RunningQueueSizeMetricName) - errs = multierr.Append(errs, err) - if err == nil { - updated.RunningQueueSize = int(runningQueueSize.GetGauge().GetValue()) - } - waitingQueueSize, err := getLatestMetric(logger, metricFamilies, WaitingQueueSizeMetricName) - errs = multierr.Append(errs, err) - if err == nil { - updated.WaitingQueueSize = int(waitingQueueSize.GetGauge().GetValue()) - } - cachePercent, err := getLatestMetric(logger, metricFamilies, KVCacheUsagePercentMetricName) - errs = multierr.Append(errs, err) - if err == nil { - updated.KVCacheUsagePercent = cachePercent.GetGauge().GetValue() - } - - loraMetrics, _, err := getLatestLoraMetric(logger, metricFamilies) - errs = multierr.Append(errs, err) - /* TODO: uncomment once this is available in vllm. - kvCap, _, err := getGaugeLatestValue(metricFamilies, KvCacheMaxTokenCapacityMetricName) - errs = multierr.Append(errs, err) - if err != nil { - updated.KvCacheMaxTokenCapacity = int(kvCap) - } - */ - - if loraMetrics != nil { - updated.ActiveModels = make(map[string]int) - for _, label := range loraMetrics.GetLabel() { - if label.GetName() == LoraRequestInfoRunningAdaptersMetricName { - if label.GetValue() != "" { - adapterList := strings.Split(label.GetValue(), ",") - for _, adapter := range adapterList { - updated.ActiveModels[adapter] = 0 - } - } - } - if label.GetName() == LoraRequestInfoWaitingAdaptersMetricName { - if label.GetValue() != "" { - adapterList := strings.Split(label.GetValue(), ",") - for _, adapter := range adapterList { - updated.ActiveModels[adapter] = 0 - } - } - } - if label.GetName() == LoraRequestInfoMaxAdaptersMetricName { - if label.GetValue() != "" { - updated.MaxActiveModels, err = strconv.Atoi(label.GetValue()) - if err != nil { - errs = multierr.Append(errs, err) - } - } - } - } - - } - - return updated, errs -} - -// getLatestLoraMetric gets latest lora metric series in gauge metric family `vllm:lora_requests_info` -// reason its specially fetched is because each label key value pair permutation generates new series -// and only most recent is useful. The value of each series is the creation timestamp so we can -// retrieve the latest by sorting the value. -func getLatestLoraMetric(logger logr.Logger, metricFamilies map[string]*dto.MetricFamily) (*dto.Metric, time.Time, error) { - loraRequests, ok := metricFamilies[LoraRequestInfoMetricName] - if !ok { - logger.V(logutil.TRACE).Error(nil, "Metric family not found", "name", LoraRequestInfoMetricName) - return nil, time.Time{}, fmt.Errorf("metric family %q not found", LoraRequestInfoMetricName) - } - - var latest *dto.Metric - var latestTs float64 - - // Iterate over all metrics in the family. - for _, m := range loraRequests.GetMetric() { - var running, waiting string - // Read the label values for running and waiting adapters. - for _, lp := range m.GetLabel() { - switch lp.GetName() { - case LoraRequestInfoRunningAdaptersMetricName: - running = lp.GetValue() - case LoraRequestInfoWaitingAdaptersMetricName: - waiting = lp.GetValue() - } - } - - // Ignore metrics with both labels empty. This happens when there are no running or waiting requests on - // the server, in this case it is best to use the last set of active adapters. - if running == "" && waiting == "" { - continue - } - - // Select the metric with the latest creation timestamp. - if m.GetGauge().GetValue() > latestTs { - latestTs = m.GetGauge().GetValue() - latest = m - } - } - - if latest == nil { - logger.V(logutil.TRACE).Info("Metric value Empty", "value", latest, "metric", LoraRequestInfoMetricName) - return nil, time.Time{}, nil - } - - // Convert the gauge value (creation timestamp) to time.Time. - return latest, time.Unix(0, int64(latestTs*1000)), nil -} - -// getLatestMetric gets the latest metric of a family. This should be used to get the latest Gauge metric. -// Since vllm doesn't set the timestamp in metric, this metric essentially gets the first metric. -func getLatestMetric(logger logr.Logger, metricFamilies map[string]*dto.MetricFamily, metricName string) (*dto.Metric, error) { - mf, ok := metricFamilies[metricName] - if !ok { - logger.V(logutil.TRACE).Error(nil, "Metric family not found", "name", metricName) - return nil, fmt.Errorf("metric family %q not found", metricName) - } - if len(mf.GetMetric()) == 0 { - return nil, fmt.Errorf("no metrics available for %q", metricName) - } - var latestTs int64 - var latest *dto.Metric - for _, m := range mf.GetMetric() { - if m.GetTimestampMs() >= latestTs { - latestTs = m.GetTimestampMs() - latest = m - } - } - logger.V(logutil.TRACE).Info("Metric value selected", "value", latest, "metric", metricName) - return latest, nil -} diff --git a/pkg/epp/backend/vllm/metrics_test.go b/pkg/epp/backend/vllm/metrics_test.go deleted file mode 100644 index 5555bd26..00000000 --- a/pkg/epp/backend/vllm/metrics_test.go +++ /dev/null @@ -1,250 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 vllm - -import ( - "errors" - "testing" - - dto "github.com/prometheus/client_model/go" - "github.com/stretchr/testify/assert" - "google.golang.org/protobuf/proto" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -func TestPromToPodMetrics(t *testing.T) { - logger := logutil.NewTestLogger() - - testCases := []struct { - name string - metricFamilies map[string]*dto.MetricFamily - initialMetrics *metrics.Metrics - expectedMetrics *metrics.Metrics - expectedErr error - }{ - { - name: "all metrics available", - metricFamilies: map[string]*dto.MetricFamily{ - RunningQueueSizeMetricName: { - Metric: []*dto.Metric{ - { - Gauge: &dto.Gauge{ - Value: proto.Float64(10), - }, - TimestampMs: proto.Int64(100), - }, - { - Gauge: &dto.Gauge{ - Value: proto.Float64(15), - }, - TimestampMs: proto.Int64(200), // This is the latest - }, - }, - }, - WaitingQueueSizeMetricName: { - Metric: []*dto.Metric{ - { - Gauge: &dto.Gauge{ - Value: proto.Float64(20), - }, - TimestampMs: proto.Int64(100), - }, - { - Gauge: &dto.Gauge{ - Value: proto.Float64(25), - }, - TimestampMs: proto.Int64(200), // This is the latest - }, - }, - }, - KVCacheUsagePercentMetricName: { - Metric: []*dto.Metric{ - { - Gauge: &dto.Gauge{ - Value: proto.Float64(0.8), - }, - TimestampMs: proto.Int64(100), - }, - { - Gauge: &dto.Gauge{ - Value: proto.Float64(0.9), - }, - TimestampMs: proto.Int64(200), // This is the latest - }, - }, - }, - LoraRequestInfoMetricName: { - Metric: []*dto.Metric{ - { - Label: []*dto.LabelPair{ - { - Name: proto.String(LoraRequestInfoRunningAdaptersMetricName), - Value: proto.String("lora3,lora4"), - }, - { - Name: proto.String(LoraRequestInfoMaxAdaptersMetricName), - Value: proto.String("2"), - }, - }, - Gauge: &dto.Gauge{ - Value: proto.Float64(100), - }, - }, - { - Label: []*dto.LabelPair{ - { - Name: proto.String(LoraRequestInfoRunningAdaptersMetricName), - Value: proto.String("lora2"), - }, - { - Name: proto.String(LoraRequestInfoMaxAdaptersMetricName), - Value: proto.String("2"), - }, - }, - Gauge: &dto.Gauge{ - Value: proto.Float64(90), - }, - }, - }, - }, - }, - expectedMetrics: &metrics.Metrics{ - RunningQueueSize: 15, - WaitingQueueSize: 25, - KVCacheUsagePercent: 0.9, - ActiveModels: map[string]int{ - "lora3": 0, - "lora4": 0, - }, - MaxActiveModels: 2, - }, - initialMetrics: &metrics.Metrics{}, - expectedErr: nil, - }, - { - name: "invalid max lora", - metricFamilies: map[string]*dto.MetricFamily{ - RunningQueueSizeMetricName: { - Metric: []*dto.Metric{ - { - Gauge: &dto.Gauge{ - Value: proto.Float64(10), - }, - TimestampMs: proto.Int64(100), - }, - { - Gauge: &dto.Gauge{ - Value: proto.Float64(15), - }, - TimestampMs: proto.Int64(200), // This is the latest - }, - }, - }, - WaitingQueueSizeMetricName: { - Metric: []*dto.Metric{ - { - Gauge: &dto.Gauge{ - Value: proto.Float64(20), - }, - TimestampMs: proto.Int64(100), - }, - { - Gauge: &dto.Gauge{ - Value: proto.Float64(25), - }, - TimestampMs: proto.Int64(200), // This is the latest - }, - }, - }, - KVCacheUsagePercentMetricName: { - Metric: []*dto.Metric{ - { - Gauge: &dto.Gauge{ - Value: proto.Float64(0.8), - }, - TimestampMs: proto.Int64(100), - }, - { - Gauge: &dto.Gauge{ - Value: proto.Float64(0.9), - }, - TimestampMs: proto.Int64(200), // This is the latest - }, - }, - }, - LoraRequestInfoMetricName: { - Metric: []*dto.Metric{ - { - Label: []*dto.LabelPair{ - { - Name: proto.String(LoraRequestInfoRunningAdaptersMetricName), - Value: proto.String("lora3,lora4"), - }, - { - Name: proto.String(LoraRequestInfoMaxAdaptersMetricName), - Value: proto.String("2a"), - }, - }, - Gauge: &dto.Gauge{ - Value: proto.Float64(100), - }, - }, - { - Label: []*dto.LabelPair{ - { - Name: proto.String(LoraRequestInfoRunningAdaptersMetricName), - Value: proto.String("lora2"), - }, - { - Name: proto.String(LoraRequestInfoMaxAdaptersMetricName), - Value: proto.String("2"), - }, - }, - Gauge: &dto.Gauge{ - Value: proto.Float64(90), - }, - }, - }, - }, - }, - expectedMetrics: &metrics.Metrics{ - RunningQueueSize: 15, - WaitingQueueSize: 25, - KVCacheUsagePercent: 0.9, - ActiveModels: map[string]int{ - "lora3": 0, - "lora4": 0, - }, - MaxActiveModels: 0, - }, - initialMetrics: &metrics.Metrics{}, - expectedErr: errors.New("strconv.Atoi: parsing '2a': invalid syntax"), - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - updated, err := promToPodMetrics(logger, tc.metricFamilies, tc.initialMetrics) - if tc.expectedErr != nil { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tc.expectedMetrics, updated) - } - }) - } -} From bbc0a90fc210cf452bd93260efc91da556662974 Mon Sep 17 00:00:00 2001 From: BenjaminBraunDev Date: Fri, 14 Mar 2025 11:55:46 -0700 Subject: [PATCH 105/260] Update GO version to 1.24 (#501) * Update go version to 1.24.0 and toolchain to 1.24.2. * Change toolkit version to 1.24.0. * Remove toolchain as per go mod tidy command. --- go.mod | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 13ad16c4..9dfcfa5a 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module sigs.k8s.io/gateway-api-inference-extension -go 1.23.0 - -toolchain go1.23.2 +go 1.24.0 require ( github.com/elastic/crd-ref-docs v0.1.0 From c4c6f2a0e24081e4364432e1ff48755f3e29750e Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Fri, 14 Mar 2025 13:41:47 -0700 Subject: [PATCH 106/260] Fixing image build and adding image building to test runs (#502) --- Dockerfile | 2 +- Makefile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 312700bc..8fb00dfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Dockerfile has specific requirement to put this ARG at the beginning: # https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact -ARG BUILDER_IMAGE=golang:1.23 +ARG BUILDER_IMAGE=golang:1.24 ARG BASE_IMAGE=gcr.io/distroless/static:nonroot ## Multistage build diff --git a/Makefile b/Makefile index c3c24892..0a02cb9c 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ BBR_IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(BBR_IMAGE_NAME) BBR_IMAGE_TAG ?= $(BBR_IMAGE_REPO):$(GIT_TAG) BASE_IMAGE ?= gcr.io/distroless/static:nonroot -BUILDER_IMAGE ?= golang:1.23 +BUILDER_IMAGE ?= golang:1.24 ifdef GO_VERSION BUILDER_IMAGE = golang:$(GO_VERSION) endif @@ -120,7 +120,7 @@ vet: ## Run go vet against code. go vet ./... .PHONY: test -test: manifests generate fmt vet envtest ## Run tests. +test: manifests generate fmt vet envtest image-build ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -race -coverprofile cover.out .PHONY: test-integration From 8fcc95fe0efbe5b371334878aa95d2cf978b1780 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:09:46 +0000 Subject: [PATCH 107/260] Create inference model/pool objects in memory instead of reading them from files (#505) --- pkg/epp/util/testing/wrappers.go | 10 ++ test/integration/epp/hermetic_test.go | 95 ++++++++----------- .../inferencepool-with-model-hermetic.yaml | 63 ------------ 3 files changed, 51 insertions(+), 117 deletions(-) delete mode 100644 test/testdata/inferencepool-with-model-hermetic.yaml diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go index c4018631..ed57d01f 100644 --- a/pkg/epp/util/testing/wrappers.go +++ b/pkg/epp/util/testing/wrappers.go @@ -129,6 +129,11 @@ func (m *InferenceModelWrapper) ModelName(modelName string) *InferenceModelWrapp return m } +func (m *InferenceModelWrapper) TargetModel(modelName string) *InferenceModelWrapper { + m.Spec.TargetModels = append(m.Spec.TargetModels, v1alpha2.TargetModel{Name: modelName}) + return m +} + func (m *InferenceModelWrapper) PoolName(poolName string) *InferenceModelWrapper { m.Spec.PoolRef = v1alpha2.PoolObjectReference{Name: v1alpha2.ObjectName(poolName)} return m @@ -187,6 +192,11 @@ func (m *InferencePoolWrapper) TargetPortNumber(p int32) *InferencePoolWrapper { return m } +func (m *InferencePoolWrapper) ExtensionRef(name string) *InferencePoolWrapper { + m.Spec.ExtensionRef = &v1alpha2.Extension{ExtensionReference: v1alpha2.ExtensionReference{Name: v1alpha2.ObjectName(name)}} + return m +} + // Obj returns the wrapped InferencePool. func (m *InferencePoolWrapper) ObjRef() *v1alpha2.InferencePool { return &m.InferencePool diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 7dc9bdb8..2962655e 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -18,10 +18,7 @@ limitations under the License. package epp import ( - "bufio" - "bytes" "context" - "errors" "fmt" "io" "net" @@ -48,7 +45,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - k8syaml "k8s.io/apimachinery/pkg/util/yaml" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" @@ -67,7 +63,6 @@ import ( runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" - "sigs.k8s.io/yaml" ) const ( @@ -1545,35 +1540,50 @@ func BeforeSuite() func() { logger.Info("Setting up hermetic ExtProc server") - // Unmarshal CRDs from file into structs - manifestsPath := filepath.Join("..", "..", "testdata", "inferencepool-with-model-hermetic.yaml") - docs, err := readDocuments(manifestsPath) - if err != nil { - logutil.Fatal(logger, err, "Can't read object manifests", "path", manifestsPath) + ns := "default" + pool := utiltesting.MakeInferencePool("vllm-llama2-7b-pool"). + Namespace(ns). + TargetPortNumber(8000). + Selector(map[string]string{"app": "vllm-llama2-7b-pool"}). + ExtensionRef("epp"). + ObjRef() + if err := k8sClient.Create(context.Background(), pool); err != nil { + logutil.Fatal(logger, err, "Unable to create inferencePool", "pool", pool.Name) } - for _, doc := range docs { - inferenceModel := &v1alpha2.InferenceModel{} - if err = yaml.Unmarshal(doc, inferenceModel); err != nil { - logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) - } - if inferenceModel.Kind == "InferenceModel" { - logger.Info("Creating inference model", "model", inferenceModel) - if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { - logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", inferenceModel.Name) - } - } + models := []*v1alpha2.InferenceModel{ + utiltesting.MakeInferenceModel("sample"). + Namespace(ns). + ModelName("sql-lora"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + TargetModel("sql-lora-1fdg2"). + ObjRef(), + utiltesting.MakeInferenceModel("sheddable"). + Namespace(ns). + ModelName("sql-lora-sheddable"). + Criticality(v1alpha2.Sheddable). + PoolName(pool.Name). + TargetModel("sql-lora-1fdg3"). + ObjRef(), + utiltesting.MakeInferenceModel("generic"). + Namespace(ns). + ModelName("my-model"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + TargetModel("my-model-12345"). + ObjRef(), + utiltesting.MakeInferenceModel("direct-model"). + Namespace(ns). + ModelName("direct-model"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + ObjRef(), } - for _, doc := range docs { - inferencePool := &v1alpha2.InferencePool{} - if err = yaml.Unmarshal(doc, inferencePool); err != nil { - logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) - } - if inferencePool.Kind == "InferencePool" { - logger.Info("Creating inference pool", "pool", inferencePool) - if err := k8sClient.Create(context.Background(), inferencePool); err != nil { - logutil.Fatal(logger, err, "Unable to create inferencePool", "poolName", inferencePool.Name) - } + for i := range models { + logger.Info("Creating inference model", "model", models[i]) + if err := k8sClient.Create(context.Background(), models[i]); err != nil { + logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", models[i].Name) } } @@ -1644,29 +1654,6 @@ func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessCli return responses, nil } -// readDocuments reads documents from file. -func readDocuments(fp string) ([][]byte, error) { - b, err := os.ReadFile(fp) - if err != nil { - return nil, err - } - - docs := [][]byte{} - reader := k8syaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(b))) - for { - // Read document - doc, err := reader.Read() - if err != nil { - if errors.Is(err, io.EOF) { - break - } - return nil, err - } - docs = append(docs, doc) - } - return docs, nil -} - func makeMetadata(endpoint string) *structpb.Struct { return &structpb.Struct{ Fields: map[string]*structpb.Value{ diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml deleted file mode 100644 index 36b6e539..00000000 --- a/test/testdata/inferencepool-with-model-hermetic.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: inference.networking.x-k8s.io/v1alpha2 -kind: InferencePool -metadata: - name: vllm-llama2-7b-pool - namespace: default -spec: - targetPortNumber: 8000 - selector: - app: vllm-llama2-7b-pool - extensionRef: - name: epp ---- -apiVersion: inference.networking.x-k8s.io/v1alpha2 -kind: InferenceModel -metadata: - name: inferencemodel-sample - namespace: default -spec: - modelName: sql-lora - criticality: Critical - poolRef: - name: vllm-llama2-7b-pool - targetModels: - - name: sql-lora-1fdg2 - weight: 100 ---- -apiVersion: inference.networking.x-k8s.io/v1alpha2 -kind: InferenceModel -metadata: - name: inferencemodel-sheddable - namespace: default -spec: - modelName: sql-lora-sheddable - poolRef: - name: vllm-llama2-7b-pool - targetModels: - - name: sql-lora-1fdg3 - weight: 100 ---- -apiVersion: inference.networking.x-k8s.io/v1alpha2 -kind: InferenceModel -metadata: - name: inferencemodel-generic - namespace: default -spec: - modelName: my-model - criticality: Critical - poolRef: - name: vllm-llama2-7b-pool - targetModels: - - name: my-model-12345 - weight: 100 ---- -apiVersion: inference.networking.x-k8s.io/v1alpha2 -kind: InferenceModel -metadata: - name: inferencemodel-direct-model-name - namespace: default -spec: - modelName: direct-model - criticality: Critical - poolRef: - name: vllm-llama2-7b-pool \ No newline at end of file From 53cb18fe97251176b00f415a661b8d765f1938a5 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:29:46 +0000 Subject: [PATCH 108/260] Refactor the integration tests setup (#506) --- test/integration/epp/hermetic_test.go | 382 ------------------------ test/integration/epp/test_suite.go | 409 ++++++++++++++++++++++++++ 2 files changed, 409 insertions(+), 382 deletions(-) create mode 100644 test/integration/epp/test_suite.go diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 2962655e..d02c9c13 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -18,66 +18,24 @@ limitations under the License. package epp import ( - "context" - "fmt" - "io" - "net" - "net/http" "os" - "path/filepath" "strconv" "strings" "testing" - "time" configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/stretchr/testify/assert" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" - k8sclient "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/config" - "sigs.k8s.io/controller-runtime/pkg/envtest" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) -const ( - port = runserver.DefaultGrpcPort - metricsPort = 8888 -) - -var ( - serverRunner *runserver.ExtProcServerRunner - k8sClient k8sclient.Client - testEnv *envtest.Environment - scheme = runtime.NewScheme() - logger = logutil.NewTestLogger().V(logutil.VERBOSE) -) - func TestMain(m *testing.M) { cleanup := BeforeSuite() code := m.Run() @@ -1399,343 +1357,3 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }) } } - -func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - // Reconfigure the TestPodMetricsClient. - res := map[types.NamespacedName]*backendmetrics.Metrics{} - for pod, metrics := range podAndMetrics { - res[pod.NamespacedName] = metrics - } - serverRunner.TestPodMetricsClient.SetRes(res) - serverRunner.UseStreaming = streamed - - serverCtx, stopServer := context.WithCancel(context.Background()) - - // TODO: this should be consistent with the inference pool - podLabels := map[string]string{ - "app": "vllm-llama2-7b-pool", - } - - for pod := range podAndMetrics { - pod := utiltesting.MakePod(pod.NamespacedName.Name). - Namespace(pod.NamespacedName.Namespace). - ReadyCondition(). - Labels(podLabels). - IP(pod.Address). - Complete(). - ObjRef() - - copy := pod.DeepCopy() - if err := k8sClient.Create(context.Background(), copy); err != nil { - logutil.Fatal(logger, err, "Failed to create pod", "pod", pod) - } - - // since no pod controllers deployed in fake environment, we manually update pod status - copy.Status = pod.Status - if err := k8sClient.Status().Update(context.Background(), copy); err != nil { - logutil.Fatal(logger, err, "Failed to update pod status", "pod", pod) - } - } - go func() { - if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { - logutil.Fatal(logger, err, "Failed to start ext-proc server") - } - }() - - // check if all pods are synced to datastore - assert.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podAndMetrics), "Datastore not synced") - }, 10*time.Second, time.Second) - - address := fmt.Sprintf("localhost:%v", port) - // Create a grpc connection - conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - logutil.Fatal(logger, err, "Failed to connect", "address", address) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) - if err != nil { - logutil.Fatal(logger, err, "Failed to create client") - } - return client, func() { - cancel() - conn.Close() - stopServer() - - // clear created pods - for pod := range podAndMetrics { - pod := utiltesting.MakePod(pod.NamespacedName.Name). - Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() - - if err := k8sClient.Delete(context.Background(), pod); err != nil { - logutil.Fatal(logger, err, "Failed to delete pod", "pod", fakePod) - } - } - // wait a little until the goroutines actually exit - time.Sleep(5 * time.Second) - } -} - -func fakePod(index int) backendmetrics.Pod { - return backendmetrics.Pod{ - NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, - Address: fmt.Sprintf("192.168.1.%d", index+1), - } -} - -// Sets up a test environment and returns the runner struct -func BeforeSuite() func() { - // Set up mock k8s API Client - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - } - cfg, err := testEnv.Start() - if err != nil { - logutil.Fatal(logger, err, "Failed to start test environment", "config", cfg) - } - - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha2.AddToScheme(scheme)) - - k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) - if err != nil { - logutil.Fatal(logger, err, "Failed to start k8s Client") - } else if k8sClient == nil { - logutil.Fatal(logger, nil, "No error, but returned kubernetes client is nil", "config", cfg) - } - - // Init runtime. - ctrl.SetLogger(logger) - - mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama2-7b-pool")) - if err != nil { - logutil.Fatal(logger, err, "Failed to create controller manager") - } - - if err := registerMetricsHandler(mgr, metricsPort); err != nil { - logutil.Fatal(logger, err, "Failed to register metrics handler") - } - - serverRunner = runserver.NewDefaultExtProcServerRunner() - serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} - pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) - // Adjust from defaults - serverRunner.PoolName = "vllm-llama2-7b-pool" - serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) - serverRunner.SecureServing = false - - if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { - logutil.Fatal(logger, err, "Failed to setup server runner") - } - - // Start the controller manager in a go routine, not blocking - go func() { - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - logutil.Fatal(logger, err, "Failed to start manager") - } - }() - - logger.Info("Setting up hermetic ExtProc server") - - ns := "default" - pool := utiltesting.MakeInferencePool("vllm-llama2-7b-pool"). - Namespace(ns). - TargetPortNumber(8000). - Selector(map[string]string{"app": "vllm-llama2-7b-pool"}). - ExtensionRef("epp"). - ObjRef() - if err := k8sClient.Create(context.Background(), pool); err != nil { - logutil.Fatal(logger, err, "Unable to create inferencePool", "pool", pool.Name) - } - - models := []*v1alpha2.InferenceModel{ - utiltesting.MakeInferenceModel("sample"). - Namespace(ns). - ModelName("sql-lora"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - TargetModel("sql-lora-1fdg2"). - ObjRef(), - utiltesting.MakeInferenceModel("sheddable"). - Namespace(ns). - ModelName("sql-lora-sheddable"). - Criticality(v1alpha2.Sheddable). - PoolName(pool.Name). - TargetModel("sql-lora-1fdg3"). - ObjRef(), - utiltesting.MakeInferenceModel("generic"). - Namespace(ns). - ModelName("my-model"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - TargetModel("my-model-12345"). - ObjRef(), - utiltesting.MakeInferenceModel("direct-model"). - Namespace(ns). - ModelName("direct-model"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - ObjRef(), - } - for i := range models { - logger.Info("Creating inference model", "model", models[i]) - if err := k8sClient.Create(context.Background(), models[i]); err != nil { - logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", models[i].Name) - } - } - - assert.Eventually(nil, func() bool { - modelExist := serverRunner.Datastore.ModelGet("my-model") - synced := serverRunner.Datastore.PoolHasSynced() && modelExist != nil - return synced - }, 10*time.Second, 10*time.Millisecond) - - return func() { - _ = testEnv.Stop() - _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferencePool{}) - _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferenceModel{}) - } -} - -func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - - res, err := client.Recv() - if err != nil { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - return res, err -} - -func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { - for _, req := range requests { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - // Brief pause for the goroutines to execute sequentially and populate the internal pipe channels sequentially - // without the pause there can be a race condition where a goroutine from a subsequent request is able to populate - // the pipe writer channel before a previous chunk. This is simply due to everything running in memory, this would - // not happen in a real world environment with non-zero latency. - time.Sleep(1 * time.Millisecond) - } - responses := []*extProcPb.ProcessingResponse{} - - // Make an incredible simple timeout func in the case where - // there is less than the expected amount of responses; bail and fail. - var simpleTimeout bool - go func() { - time.Sleep(10 * time.Second) - simpleTimeout = true - }() - - for range expectedResponses { - if simpleTimeout { - break - } - res, err := client.Recv() - if err != nil && err != io.EOF { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - responses = append(responses, res) - } - return responses, nil -} - -func makeMetadata(endpoint string) *structpb.Struct { - return &structpb.Struct{ - Fields: map[string]*structpb.Value{ - runserver.DefaultDestinationEndpointHintMetadataNamespace: { - Kind: &structpb.Value_StructValue{ - StructValue: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - runserver.DefaultDestinationEndpointHintKey: { - Kind: &structpb.Value_StringValue{ - StringValue: endpoint, - }, - }, - }, - }, - }, - }, - }, - } -} - -// registerMetricsHandler is a simplified version of metrics endpoint handler -// without Authentication for integration tests. -func registerMetricsHandler(mgr manager.Manager, port int) error { - metrics.Register() - - // Init HTTP server. - h := promhttp.HandlerFor( - legacyregistry.DefaultGatherer, - promhttp.HandlerOpts{}, - ) - - mux := http.NewServeMux() - mux.Handle("/metrics", h) - - srv := &http.Server{ - Addr: net.JoinHostPort("", strconv.Itoa(port)), - Handler: mux, - } - - if err := mgr.Add(&manager.Server{ - Name: "metrics", - Server: srv, - }); err != nil { - return err - } - return nil -} - -// inject options that allow multiple test runs to run -// https://github.com/kubernetes-sigs/controller-runtime/issues/2937 -func managerTestOptions(namespace, name string) ctrl.Options { - return ctrl.Options{ - Scheme: scheme, - Cache: cache.Options{ - ByObject: map[client.Object]cache.ByObject{ - &corev1.Pod{}: { - Namespaces: map[string]cache.Config{ - namespace: {}, - }, - }, - &v1alpha2.InferencePool{}: { - Namespaces: map[string]cache.Config{ - namespace: { - FieldSelector: fields.SelectorFromSet(fields.Set{ - "metadata.name": name, - }), - }, - }, - }, - &v1alpha2.InferenceModel{}: { - Namespaces: map[string]cache.Config{ - namespace: {}, - }, - }, - }, - }, - Controller: config.Controller{ - SkipNameValidation: boolPointer(true), - }, - } -} - -func boolPointer(b bool) *bool { - return &b -} diff --git a/test/integration/epp/test_suite.go b/test/integration/epp/test_suite.go new file mode 100644 index 00000000..b63a6775 --- /dev/null +++ b/test/integration/epp/test_suite.go @@ -0,0 +1,409 @@ +/* +Copyright 2025 The Kubernetes 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 epp contains integration tests for the ext proc while faking the backend pods. +package epp + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "path/filepath" + "strconv" + "testing" + "time" + + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/component-base/metrics/legacyregistry" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" +) + +const ( + port = runserver.DefaultGrpcPort + metricsPort = 8888 +) + +var ( + serverRunner *runserver.ExtProcServerRunner + k8sClient k8sclient.Client + testEnv *envtest.Environment + scheme = runtime.NewScheme() + logger = logutil.NewTestLogger().V(logutil.VERBOSE) +) + +func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + // Reconfigure the TestPodMetricsClient. + res := map[types.NamespacedName]*backendmetrics.Metrics{} + for pod, metrics := range podAndMetrics { + res[pod.NamespacedName] = metrics + } + serverRunner.TestPodMetricsClient.SetRes(res) + serverRunner.UseStreaming = streamed + + serverCtx, stopServer := context.WithCancel(context.Background()) + + // TODO: this should be consistent with the inference pool + podLabels := map[string]string{ + "app": "vllm-llama2-7b-pool", + } + + for pod := range podAndMetrics { + pod := utiltesting.MakePod(pod.NamespacedName.Name). + Namespace(pod.NamespacedName.Namespace). + ReadyCondition(). + Labels(podLabels). + IP(pod.Address). + Complete(). + ObjRef() + + copy := pod.DeepCopy() + if err := k8sClient.Create(context.Background(), copy); err != nil { + logutil.Fatal(logger, err, "Failed to create pod", "pod", pod) + } + + // since no pod controllers deployed in fake environment, we manually update pod status + copy.Status = pod.Status + if err := k8sClient.Status().Update(context.Background(), copy); err != nil { + logutil.Fatal(logger, err, "Failed to update pod status", "pod", pod) + } + } + go func() { + if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { + logutil.Fatal(logger, err, "Failed to start ext-proc server") + } + }() + + // check if all pods are synced to datastore + assert.EventuallyWithT(t, func(t *assert.CollectT) { + assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podAndMetrics), "Datastore not synced") + }, 10*time.Second, time.Second) + + address := fmt.Sprintf("localhost:%v", port) + // Create a grpc connection + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + logutil.Fatal(logger, err, "Failed to connect", "address", address) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) + if err != nil { + logutil.Fatal(logger, err, "Failed to create client") + } + return client, func() { + cancel() + conn.Close() + stopServer() + + // clear created pods + for pod := range podAndMetrics { + pod := utiltesting.MakePod(pod.NamespacedName.Name). + Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() + + if err := k8sClient.Delete(context.Background(), pod); err != nil { + logutil.Fatal(logger, err, "Failed to delete pod", "pod", fakePod) + } + } + // wait a little until the goroutines actually exit + time.Sleep(5 * time.Second) + } +} + +func fakePod(index int) backendmetrics.Pod { + return backendmetrics.Pod{ + NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, + Address: fmt.Sprintf("192.168.1.%d", index+1), + } +} + +// Sets up a test environment and returns the runner struct +func BeforeSuite() func() { + // Set up mock k8s API Client + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + cfg, err := testEnv.Start() + if err != nil { + logutil.Fatal(logger, err, "Failed to start test environment", "config", cfg) + } + + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.AddToScheme(scheme)) + + k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) + if err != nil { + logutil.Fatal(logger, err, "Failed to start k8s Client") + } else if k8sClient == nil { + logutil.Fatal(logger, nil, "No error, but returned kubernetes client is nil", "config", cfg) + } + + // Init runtime. + ctrl.SetLogger(logger) + + mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama2-7b-pool")) + if err != nil { + logutil.Fatal(logger, err, "Failed to create controller manager") + } + + if err := registerMetricsHandler(mgr, metricsPort); err != nil { + logutil.Fatal(logger, err, "Failed to register metrics handler") + } + + serverRunner = runserver.NewDefaultExtProcServerRunner() + serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} + pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) + // Adjust from defaults + serverRunner.PoolName = "vllm-llama2-7b-pool" + serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) + serverRunner.SecureServing = false + + if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { + logutil.Fatal(logger, err, "Failed to setup server runner") + } + + // Start the controller manager in a go routine, not blocking + go func() { + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logutil.Fatal(logger, err, "Failed to start manager") + } + }() + + logger.Info("Setting up hermetic ExtProc server") + + ns := "default" + pool := utiltesting.MakeInferencePool("vllm-llama2-7b-pool"). + Namespace(ns). + TargetPortNumber(8000). + Selector(map[string]string{"app": "vllm-llama2-7b-pool"}). + ExtensionRef("epp"). + ObjRef() + if err := k8sClient.Create(context.Background(), pool); err != nil { + logutil.Fatal(logger, err, "Unable to create inferencePool", "pool", pool.Name) + } + + models := []*v1alpha2.InferenceModel{ + utiltesting.MakeInferenceModel("sample"). + Namespace(ns). + ModelName("sql-lora"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + TargetModel("sql-lora-1fdg2"). + ObjRef(), + utiltesting.MakeInferenceModel("sheddable"). + Namespace(ns). + ModelName("sql-lora-sheddable"). + Criticality(v1alpha2.Sheddable). + PoolName(pool.Name). + TargetModel("sql-lora-1fdg3"). + ObjRef(), + utiltesting.MakeInferenceModel("generic"). + Namespace(ns). + ModelName("my-model"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + TargetModel("my-model-12345"). + ObjRef(), + utiltesting.MakeInferenceModel("direct-model"). + Namespace(ns). + ModelName("direct-model"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + ObjRef(), + } + for i := range models { + logger.Info("Creating inference model", "model", models[i]) + if err := k8sClient.Create(context.Background(), models[i]); err != nil { + logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", models[i].Name) + } + } + + assert.Eventually(nil, func() bool { + modelExist := serverRunner.Datastore.ModelGet("my-model") + synced := serverRunner.Datastore.PoolHasSynced() && modelExist != nil + return synced + }, 10*time.Second, 10*time.Millisecond) + + return func() { + _ = testEnv.Stop() + _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferencePool{}) + _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferenceModel{}) + } +} + +func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + + res, err := client.Recv() + if err != nil { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + return res, err +} + +func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { + for _, req := range requests { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + // Brief pause for the goroutines to execute sequentially and populate the internal pipe channels sequentially + // without the pause there can be a race condition where a goroutine from a subsequent request is able to populate + // the pipe writer channel before a previous chunk. This is simply due to everything running in memory, this would + // not happen in a real world environment with non-zero latency. + time.Sleep(1 * time.Millisecond) + } + responses := []*extProcPb.ProcessingResponse{} + + // Make an incredible simple timeout func in the case where + // there is less than the expected amount of responses; bail and fail. + var simpleTimeout bool + go func() { + time.Sleep(10 * time.Second) + simpleTimeout = true + }() + + for range expectedResponses { + if simpleTimeout { + break + } + res, err := client.Recv() + if err != nil && err != io.EOF { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + responses = append(responses, res) + } + return responses, nil +} + +func makeMetadata(endpoint string) *structpb.Struct { + return &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultDestinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultDestinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + }, + }, + }, + }, + } +} + +// registerMetricsHandler is a simplified version of metrics endpoint handler +// without Authentication for integration tests. +func registerMetricsHandler(mgr manager.Manager, port int) error { + metrics.Register() + + // Init HTTP server. + h := promhttp.HandlerFor( + legacyregistry.DefaultGatherer, + promhttp.HandlerOpts{}, + ) + + mux := http.NewServeMux() + mux.Handle("/metrics", h) + + srv := &http.Server{ + Addr: net.JoinHostPort("", strconv.Itoa(port)), + Handler: mux, + } + + if err := mgr.Add(&manager.Server{ + Name: "metrics", + Server: srv, + }); err != nil { + return err + } + return nil +} + +// inject options that allow multiple test runs to run +// https://github.com/kubernetes-sigs/controller-runtime/issues/2937 +func managerTestOptions(namespace, name string) ctrl.Options { + return ctrl.Options{ + Scheme: scheme, + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &corev1.Pod{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + &v1alpha2.InferencePool{}: { + Namespaces: map[string]cache.Config{ + namespace: { + FieldSelector: fields.SelectorFromSet(fields.Set{ + "metadata.name": name, + }), + }, + }, + }, + &v1alpha2.InferenceModel{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + }, + }, + Controller: config.Controller{ + SkipNameValidation: ptr.To(true), + }, + } +} From f358339940071d50fdb8c1b4d81ae485995997a8 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:23:49 +0000 Subject: [PATCH 109/260] fix log line (#509) --- pkg/epp/scheduling/scheduler.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 82410787..c861996a 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -124,13 +124,14 @@ type Scheduler struct { func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backendmetrics.PodMetrics, err error) { logger := log.FromContext(ctx).WithValues("request", req) podMetrics := s.datastore.PodGetAll() - logger.V(logutil.VERBOSE).Info("Scheduling a request", "metrics", podMetrics) + + logger.V(logutil.VERBOSE).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", podMetrics)) pods, err := s.filter.Filter(logger, req, podMetrics) if err != nil || len(pods) == 0 { return nil, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } - logger.V(logutil.VERBOSE).Info("Selecting a random pod from the candidates", "candidatePods", pods) + logger.V(logutil.VERBOSE).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) i := rand.Intn(len(pods)) return pods[i], nil } From e014105c84c74259370e60a29c4eafedd6d88ba6 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 17 Mar 2025 19:13:49 +0200 Subject: [PATCH 110/260] update release version (#512) Signed-off-by: Nir Rozenbaum --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ad19cdb..892ab8a5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It currently requires a version of vLLM that supports the necessary metrics to p ## Status -This project is [alpha (0.1 release)](https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/tag/v0.1.0). It should not be used in production yet. +This project is [alpha (0.2 release)](https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/tag/v0.2.0). It should not be used in production yet. ## Getting Started From 7f839ae791e7884422943d5eb199e58d34542c59 Mon Sep 17 00:00:00 2001 From: BenjaminBraunDev Date: Mon, 17 Mar 2025 13:11:50 -0700 Subject: [PATCH 111/260] Add nil option for metric_spec to specify metrics to not be scraped. (#503) * Add nil option for metric_spec to specify metrics to not be scraped. * Add logging when a metric is not being scraped when set as an empty string. * Move unscraped metric setup logging to main. * Update Dockerfile go version from 1.23 to 1.24 --- cmd/epp/main.go | 14 ++++++++++++++ pkg/epp/backend/metrics/metrics_spec.go | 3 +++ pkg/epp/backend/metrics/metrics_spec_test.go | 15 ++++++--------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index fa63f0bc..39baf18b 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -163,6 +163,7 @@ func run() error { setupLog.Error(err, "Failed to create metric mapping from flags.") return err } + verifyMetricMapping(*mapping, setupLog) pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.PodMetricsClientImpl{MetricMapping: mapping}, *refreshMetricsInterval) // Setup runner. @@ -304,3 +305,16 @@ func validateFlags() error { return nil } + +func verifyMetricMapping(mapping backendmetrics.MetricMapping, logger logr.Logger) { + if mapping.TotalQueuedRequests == nil { + logger.Info("Not scraping metric: TotalQueuedRequests") + } + if mapping.KVCacheUtilization == nil { + logger.Info("Not scraping metric: KVCacheUtilization") + } + if mapping.LoraRequestInfo == nil { + logger.Info("Not scraping metric: LoraRequestInfo") + } + +} diff --git a/pkg/epp/backend/metrics/metrics_spec.go b/pkg/epp/backend/metrics/metrics_spec.go index ce0c075d..f6f904a9 100644 --- a/pkg/epp/backend/metrics/metrics_spec.go +++ b/pkg/epp/backend/metrics/metrics_spec.go @@ -41,6 +41,9 @@ type MetricMapping struct { // "metric_name{label1=value1}" // "metric_name{label1=value1,label2=value2}" func stringToMetricSpec(specStr string) (*MetricSpec, error) { + if specStr == "" { + return nil, nil // Allow empty strings to represent nil MetricSpecs + } specStr = strings.TrimSpace(specStr) metricName := specStr labels := make(map[string]string) diff --git a/pkg/epp/backend/metrics/metrics_spec_test.go b/pkg/epp/backend/metrics/metrics_spec_test.go index 82804206..e62bc5ff 100644 --- a/pkg/epp/backend/metrics/metrics_spec_test.go +++ b/pkg/epp/backend/metrics/metrics_spec_test.go @@ -32,7 +32,7 @@ func TestStringToMetricSpec(t *testing.T) { name: "empty string", input: "", want: nil, - wantErr: true, + wantErr: false, }, { name: "no labels", @@ -152,14 +152,9 @@ func TestStringToMetricSpec(t *testing.T) { t.Errorf("stringToMetricSpec() error = %v, wantErr %v", err, tt.wantErr) return } - if tt.wantErr { - if got != nil { // handles if we got a nil spec and didn't expect an error - t.Errorf("stringToMetricSpec() = %v, want %v", got, tt.want) - return - } - } else { - if got == nil { - t.Fatalf("stringToMetricSpec() = got nil but wanted %v", tt.want) + if tt.want != nil && got != nil { // compare maps directly + if tt.want.Labels == nil { + tt.want.Labels = make(map[string]string) } if !reflect.DeepEqual(got.MetricName, tt.want.MetricName) { t.Errorf("stringToMetricSpec() got MetricName = %v, want %v", got.MetricName, tt.want.MetricName) @@ -167,6 +162,8 @@ func TestStringToMetricSpec(t *testing.T) { if !reflect.DeepEqual(got.Labels, tt.want.Labels) { t.Errorf("stringToMetricSpec() got Labels = %v, want %v", got.Labels, tt.want.Labels) } + } else if tt.want != got { // handles if one is nil and the other isn't + t.Errorf("stringToMetricSpec() = %v, want %v", got, tt.want) } }) } From ba867c56e3ad6fa95dd5da8f97903b98958f24c4 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 17 Mar 2025 22:33:49 +0200 Subject: [PATCH 112/260] switch to using formal vllm-cpu image (#511) * switch to formal vllm-cpu image Signed-off-by: Nir Rozenbaum * documentation of formal vllm-cpu image Signed-off-by: Nir Rozenbaum * minor updates to cpu deployment Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- config/manifests/vllm/cpu-deployment.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml index a0925c83..76865e4c 100644 --- a/config/manifests/vllm/cpu-deployment.yaml +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -14,7 +14,7 @@ spec: spec: containers: - name: lora - image: "seedjeffwan/vllm-cpu-env:bb392af4-20250203" + image: "public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo:v0.7.2" # formal images can be found in https://gallery.ecr.aws/q9t5s3a7/vllm-cpu-release-repo imagePullPolicy: Always command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] args: @@ -23,9 +23,11 @@ spec: - "--port" - "8000" - "--enable-lora" + - "--max-loras" + - "4" - "--lora-modules" - - '{"name": "tweet-summary-0", "path": "/adapters/hub/models--ai-blond--Qwen-Qwen2.5-Coder-1.5B-Instruct-lora/snapshots/9cde18d8ed964b0519fb481cca6acd936b2ca811"}' - - '{"name": "tweet-summary-1", "path": "/adapters/hub/models--ai-blond--Qwen-Qwen2.5-Coder-1.5B-Instruct-lora/snapshots/9cde18d8ed964b0519fb481cca6acd936b2ca811"}' + - '{"name": "tweet-summary-0", "path": "/adapters/ai-blond/Qwen-Qwen2.5-Coder-1.5B-Instruct-lora_0"}' + - '{"name": "tweet-summary-1", "path": "/adapters/ai-blond/Qwen-Qwen2.5-Coder-1.5B-Instruct-lora_1"}' env: - name: PORT value: "8000" @@ -36,6 +38,8 @@ spec: key: token - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING value: "true" + - name: VLLM_CPU_KVCACHE_SPACE + value: "4" ports: - containerPort: 8000 name: http From d7a9dfa0f2d4d2a719007054ba66fd2e6d0290bb Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 17 Mar 2025 13:33:56 -0700 Subject: [PATCH 113/260] cleanup logging (#514) * cleanup logging * Update cmd/epp/health.go Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> --------- Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> --- cmd/epp/health.go | 4 ++-- pkg/epp/handlers/streamingserver.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/epp/health.go b/cmd/epp/health.go index 335c0849..93697002 100644 --- a/cmd/epp/health.go +++ b/cmd/epp/health.go @@ -34,10 +34,10 @@ type healthServer struct { func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) { if !s.datastore.PoolHasSynced() { - s.logger.V(logutil.VERBOSE).Info("gRPC health check not serving", "service", in.Service) + s.logger.V(logutil.DEFAULT).Info("gRPC health check not serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil } - s.logger.V(logutil.VERBOSE).Info("gRPC health check serving", "service", in.Service) + s.logger.V(logutil.TRACE).Info("gRPC health check serving", "service", in.Service) return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil } diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index adcd83ed..0e2fbd1c 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -133,7 +133,8 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) loggerVerbose.Info("got response headers", "headers", v.ResponseHeaders.Headers.GetHeaders()) for _, header := range v.ResponseHeaders.Headers.GetHeaders() { value := string(header.RawValue) - logger.Error(nil, "header", "key", header.Key, "value", value) + + logger.V(logutil.TRACE).Info("header", "key", header.Key, "value", value) if header.Key == "status" && value != "200" { reqCtx.ResponseStatusCode = errutil.ModelServerError } else if header.Key == "content-type" && strings.Contains(value, "text/event-stream") { From a591cd04100c425c4b85c020a83f85379c6752e3 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Mon, 17 Mar 2025 21:57:49 +0000 Subject: [PATCH 114/260] Rename ext_proc.yaml to inferencepool.yaml (#515) * rename ext_proc.yaml to inferencepool.yaml * removed ext-proc suffix * rename my-pool to vllm-llama2-7b --- .../{ext_proc.yaml => inferencepool.yaml} | 124 +++++++++--------- config/manifests/vllm/cpu-deployment.yaml | 6 +- config/manifests/vllm/gpu-deployment.yaml | 6 +- site-src/guides/index.md | 6 +- test/e2e/epp/e2e_suite_test.go | 6 +- test/testdata/envoy.yaml | 4 +- 6 files changed, 76 insertions(+), 76 deletions(-) rename config/manifests/{ext_proc.yaml => inferencepool.yaml} (86%) diff --git a/config/manifests/ext_proc.yaml b/config/manifests/inferencepool.yaml similarity index 86% rename from config/manifests/ext_proc.yaml rename to config/manifests/inferencepool.yaml index d70467ee..64008639 100644 --- a/config/manifests/ext_proc.yaml +++ b/config/manifests/inferencepool.yaml @@ -1,81 +1,53 @@ -kind: ClusterRole -apiVersion: rbac.authorization.k8s.io/v1 -metadata: - name: pod-read -rules: -- apiGroups: ["inference.networking.x-k8s.io"] - resources: ["inferencemodels"] - verbs: ["get", "watch", "list"] -- apiGroups: [""] - resources: ["pods"] - verbs: ["get", "watch", "list"] -- apiGroups: ["inference.networking.x-k8s.io"] - resources: ["inferencepools"] - verbs: ["get", "watch", "list"] -- apiGroups: ["discovery.k8s.io"] - resources: ["endpointslices"] - verbs: ["get", "watch", "list"] -- apiGroups: - - authentication.k8s.io - resources: - - tokenreviews - verbs: - - create -- apiGroups: - - authorization.k8s.io - resources: - - subjectaccessreviews - verbs: - - create ---- -kind: ClusterRoleBinding -apiVersion: rbac.authorization.k8s.io/v1 -metadata: - name: pod-read-binding -subjects: -- kind: ServiceAccount - name: default - namespace: default -roleRef: - kind: ClusterRole - name: pod-read ---- apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: labels: - name: my-pool + name: vllm-llama2-7b spec: targetPortNumber: 8000 selector: - app: my-pool + app: vllm-llama2-7b extensionRef: - name: inference-gateway-ext-proc + name: vllm-llama2-7b-epp +--- +apiVersion: v1 +kind: Service +metadata: + name: vllm-llama2-7b-epp + namespace: default +spec: + selector: + app: vllm-llama2-7b-epp + ports: + - protocol: TCP + port: 9002 + targetPort: 9002 + type: ClusterIP --- apiVersion: apps/v1 kind: Deployment metadata: - name: inference-gateway-ext-proc + name: vllm-llama2-7b-epp namespace: default labels: - app: inference-gateway-ext-proc + app: vllm-llama2-7b-epp spec: replicas: 1 selector: matchLabels: - app: inference-gateway-ext-proc + app: vllm-llama2-7b-epp template: metadata: labels: - app: inference-gateway-ext-proc + app: vllm-llama2-7b-epp spec: containers: - - name: inference-gateway-ext-proc + - name: epp image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/epp:main imagePullPolicy: Always args: - -poolName - - "my-pool" + - "vllm-llama2-7b" - -v - "4" - -grpcPort @@ -103,16 +75,44 @@ spec: initialDelaySeconds: 5 periodSeconds: 10 --- -apiVersion: v1 -kind: Service +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 metadata: - name: inference-gateway-ext-proc + name: pod-read +rules: +- apiGroups: ["inference.networking.x-k8s.io"] + resources: ["inferencemodels"] + verbs: ["get", "watch", "list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"] +- apiGroups: ["inference.networking.x-k8s.io"] + resources: ["inferencepools"] + verbs: ["get", "watch", "list"] +- apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "watch", "list"] +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: pod-read-binding +subjects: +- kind: ServiceAccount + name: default namespace: default -spec: - selector: - app: inference-gateway-ext-proc - ports: - - protocol: TCP - port: 9002 - targetPort: 9002 - type: ClusterIP +roleRef: + kind: ClusterRole + name: pod-read diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml index 76865e4c..5ca20d1a 100644 --- a/config/manifests/vllm/cpu-deployment.yaml +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -1,16 +1,16 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: my-pool + name: vllm-llama2-7b spec: replicas: 3 selector: matchLabels: - app: my-pool + app: vllm-llama2-7b template: metadata: labels: - app: my-pool + app: vllm-llama2-7b spec: containers: - name: lora diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index d16a46a4..cdc4d82c 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -1,16 +1,16 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: my-pool + name: vllm-llama2-7b spec: replicas: 3 selector: matchLabels: - app: my-pool + app: vllm-llama2-7b template: metadata: labels: - app: my-pool + app: vllm-llama2-7b spec: containers: - name: lora diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 94f5c9c1..d6ff8459 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -80,10 +80,10 @@ This quickstart guide is intended for engineers familiar with k8s and model serv NAME CLASS ADDRESS PROGRAMMED AGE inference-gateway inference-gateway True 22s ``` -### Deploy the Inference Extension and InferencePool +### Deploy the InferencePool and Extension ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/ext_proc.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool.yaml ``` ### Deploy Envoy Gateway Custom Policies @@ -134,4 +134,4 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml --ignore-not-found kubectl delete secret hf-token --ignore-not-found - ``` \ No newline at end of file + ``` diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index bc7dc87a..92521bf7 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -57,7 +57,7 @@ const ( // TODO [danehans]: Must be "default" until https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/227 is fixed nsName = "default" // modelServerName is the name of the model server test resources. - modelServerName = "my-pool" + modelServerName = "vllm-llama2-7b" // modelName is the test model name. modelName = "tweet-summary" // envoyName is the name of the envoy proxy test resources. @@ -65,7 +65,7 @@ const ( // envoyPort is the listener port number of the test envoy proxy. envoyPort = "8081" // inferExtName is the name of the inference extension test resources. - inferExtName = "inference-gateway-ext-proc" + inferExtName = "vllm-llama2-7b-epp" // clientManifest is the manifest for the client test resources. clientManifest = "../../testdata/client.yaml" // modelServerSecretManifest is the manifest for the model server secret resource. @@ -75,7 +75,7 @@ const ( // inferModelManifest is the manifest for the inference model CRD. inferModelManifest = "../../../config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml" // inferExtManifest is the manifest for the inference extension test resources. - inferExtManifest = "../../../config/manifests/ext_proc.yaml" + inferExtManifest = "../../../config/manifests/inferencepool.yaml" // envoyManifest is the manifest for the envoy proxy test resources. envoyManifest = "../../testdata/envoy.yaml" // modelServerManifestFilepathEnvVar is the env var that holds absolute path to the manifest for the model server test resource. diff --git a/test/testdata/envoy.yaml b/test/testdata/envoy.yaml index ffb8add7..2598428c 100644 --- a/test/testdata/envoy.yaml +++ b/test/testdata/envoy.yaml @@ -100,7 +100,7 @@ data: grpc_service: envoy_grpc: cluster_name: ext_proc - authority: inference-gateway-ext-proc.default:9002 + authority: vllm-llama2-7b-epp.default:9002 timeout: 10s processing_mode: request_header_mode: SEND @@ -194,7 +194,7 @@ data: - endpoint: address: socket_address: - address: inference-gateway-ext-proc.default + address: vllm-llama2-7b-epp.default port_value: 9002 health_status: HEALTHY load_balancing_weight: 1 From f7361d5489d4a4bd0b9e369aa90d9aad1aa5aeab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:13:49 -0700 Subject: [PATCH 115/260] Bump the kubernetes group with 6 updates (#520) Bumps the kubernetes group with 6 updates: | Package | From | To | | --- | --- | --- | | [k8s.io/api](https://github.com/kubernetes/api) | `0.32.2` | `0.32.3` | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.32.2` | `0.32.3` | | [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `0.32.2` | `0.32.3` | | [k8s.io/client-go](https://github.com/kubernetes/client-go) | `0.32.2` | `0.32.3` | | [k8s.io/code-generator](https://github.com/kubernetes/code-generator) | `0.32.2` | `0.32.3` | | [k8s.io/component-base](https://github.com/kubernetes/component-base) | `0.32.2` | `0.32.3` | Updates `k8s.io/api` from 0.32.2 to 0.32.3 - [Commits](https://github.com/kubernetes/api/compare/v0.32.2...v0.32.3) Updates `k8s.io/apiextensions-apiserver` from 0.32.2 to 0.32.3 - [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases) - [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.32.2...v0.32.3) Updates `k8s.io/apimachinery` from 0.32.2 to 0.32.3 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.32.2...v0.32.3) Updates `k8s.io/client-go` from 0.32.2 to 0.32.3 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.32.2...v0.32.3) Updates `k8s.io/code-generator` from 0.32.2 to 0.32.3 - [Commits](https://github.com/kubernetes/code-generator/compare/v0.32.2...v0.32.3) Updates `k8s.io/component-base` from 0.32.2 to 0.32.3 - [Commits](https://github.com/kubernetes/component-base/compare/v0.32.2...v0.32.3) --- updated-dependencies: - dependency-name: k8s.io/api dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apiextensions-apiserver dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apimachinery dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/client-go dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/code-generator dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/component-base dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 16 ++++++++-------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 9dfcfa5a..2c03b032 100644 --- a/go.mod +++ b/go.mod @@ -17,16 +17,15 @@ require ( go.uber.org/zap v1.27.0 google.golang.org/grpc v1.71.0 google.golang.org/protobuf v1.36.5 - k8s.io/api v0.32.2 - k8s.io/apiextensions-apiserver v0.32.2 - k8s.io/apimachinery v0.32.2 - k8s.io/client-go v0.32.2 - k8s.io/code-generator v0.32.2 - k8s.io/component-base v0.32.2 + k8s.io/api v0.32.3 + k8s.io/apiextensions-apiserver v0.32.3 + k8s.io/apimachinery v0.32.3 + k8s.io/client-go v0.32.3 + k8s.io/code-generator v0.32.3 + k8s.io/component-base v0.32.3 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.3 sigs.k8s.io/structured-merge-diff/v4 v4.6.0 - sigs.k8s.io/yaml v1.4.0 ) require ( @@ -123,11 +122,12 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.32.2 // indirect + k8s.io/apiserver v0.32.3 // indirect k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect sigs.k8s.io/controller-tools v0.14.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 463e55ff..6829248f 100644 --- a/go.sum +++ b/go.sum @@ -296,20 +296,20 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.32.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw= -k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y= -k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= -k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= -k8s.io/apimachinery v0.32.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ= -k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/apiserver v0.32.2 h1:WzyxAu4mvLkQxwD9hGa4ZfExo3yZZaYzoYvvVDlM6vw= -k8s.io/apiserver v0.32.2/go.mod h1:PEwREHiHNU2oFdte7BjzA1ZyjWjuckORLIK/wLV5goM= -k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= -k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= -k8s.io/code-generator v0.32.2 h1:CIvyPrLWP7cMgrqval2qYT839YAwCDeSvGfXgWSNpHQ= -k8s.io/code-generator v0.32.2/go.mod h1:plh7bWk7JztAUkHM4zpbdy0KOMdrhsePcZL2HLWFH7Y= -k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU= -k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0= +k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= +k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= +k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apiserver v0.32.3 h1:kOw2KBuHOA+wetX1MkmrxgBr648ksz653j26ESuWNY8= +k8s.io/apiserver v0.32.3/go.mod h1:q1x9B8E/WzShF49wh3ADOh6muSfpmFL0I2t+TG0Zdgc= +k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= +k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/code-generator v0.32.3 h1:31p2TVzC9+hVdSkAFruAk3JY+iSfzrJ83Qij1yZutyw= +k8s.io/code-generator v0.32.3/go.mod h1:+mbiYID5NLsBuqxjQTygKM/DAdKpAjvBzrJd64NU1G8= +k8s.io/component-base v0.32.3 h1:98WJvvMs3QZ2LYHBzvltFSeJjEx7t5+8s71P7M74u8k= +k8s.io/component-base v0.32.3/go.mod h1:LWi9cR+yPAv7cu2X9rZanTiFKB2kHA+JjmhkKjCZRpI= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkvvIeH8UqffFJDl0bz4= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= From 84a4b909f238ed75d89108c18130378a0f06d6e6 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Mon, 17 Mar 2025 22:33:48 +0000 Subject: [PATCH 116/260] Update extension-policy to match the new epp service name (#522) --- .../manifests/gateway/extension_policy.yaml | 32 ------------------ config/manifests/inferencemodel.yaml | 6 ++-- config/manifests/inferencepool.yaml | 33 +++++++++++++++++++ site-src/guides/index.md | 3 +- 4 files changed, 37 insertions(+), 37 deletions(-) delete mode 100644 config/manifests/gateway/extension_policy.yaml diff --git a/config/manifests/gateway/extension_policy.yaml b/config/manifests/gateway/extension_policy.yaml deleted file mode 100644 index 14b7b123..00000000 --- a/config/manifests/gateway/extension_policy.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: EnvoyExtensionPolicy -metadata: - name: ext-proc-policy - namespace: default -spec: - extProc: - - backendRefs: - - group: "" - kind: Service - name: inference-gateway-ext-proc - port: 9002 - processingMode: - allowModeOverride: true - request: - body: Buffered - response: - # The timeouts are likely not needed here. We can experiment with removing/tuning them slowly. - # The connection limits are more important and will cause the opaque: ext_proc_gRPC_error_14 error in Envoy GW if not configured correctly. - messageTimeout: 1000s - backendSettings: - circuitBreaker: - maxConnections: 40000 - maxPendingRequests: 40000 - maxParallelRequests: 40000 - timeout: - tcp: - connectTimeout: 24h - targetRef: - group: gateway.networking.k8s.io - kind: HTTPRoute - name: llm-route diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 8374c5b3..4c7824ca 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -6,7 +6,7 @@ spec: modelName: tweet-summary criticality: Critical poolRef: - name: my-pool + name: vllm-llama2-7b targetModels: - name: tweet-summary-1 weight: 100 @@ -20,7 +20,7 @@ spec: modelName: meta-llama/Llama-2-7b-hf criticality: Critical poolRef: - name: my-pool + name: vllm-llama2-7b --- apiVersion: inference.networking.x-k8s.io/v1alpha2 @@ -31,4 +31,4 @@ spec: modelName: Qwen/Qwen2.5-1.5B-Instruct criticality: Critical poolRef: - name: my-pool + name: vllm-llama2-7b diff --git a/config/manifests/inferencepool.yaml b/config/manifests/inferencepool.yaml index 64008639..8225bd7c 100644 --- a/config/manifests/inferencepool.yaml +++ b/config/manifests/inferencepool.yaml @@ -75,6 +75,39 @@ spec: initialDelaySeconds: 5 periodSeconds: 10 --- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: ext-proc-policy + namespace: default +spec: + extProc: + - backendRefs: + - group: "" + kind: Service + name: vllm-llama2-7b-epp + port: 9002 + processingMode: + allowModeOverride: true + request: + body: Buffered + response: + # The timeouts are likely not needed here. We can experiment with removing/tuning them slowly. + # The connection limits are more important and will cause the opaque: ext_proc_gRPC_error_14 error in Envoy GW if not configured correctly. + messageTimeout: 1000s + backendSettings: + circuitBreaker: + maxConnections: 40000 + maxPendingRequests: 40000 + maxParallelRequests: 40000 + timeout: + tcp: + connectTimeout: 24h + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: llm-route +--- kind: ClusterRole apiVersion: rbac.authorization.k8s.io/v1 metadata: diff --git a/site-src/guides/index.md b/site-src/guides/index.md index d6ff8459..d721e73f 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -88,7 +88,6 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Deploy Envoy Gateway Custom Policies ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/extension_policy.yaml kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/patch_policy.yaml ``` > **_NOTE:_** This is also per InferencePool, and will need to be configured to support the new pool should you wish to experiment further. @@ -125,7 +124,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/traffic_policy.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/extension_policy.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/patch_policy.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/ext_proc.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gateway.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/enable_patch_policy.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml --ignore-not-found From 561577d2fd43a411751890c478f717511b7138b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:49:50 -0700 Subject: [PATCH 117/260] Bump github.com/prometheus/common from 0.62.0 to 0.63.0 (#519) Bumps [github.com/prometheus/common](https://github.com/prometheus/common) from 0.62.0 to 0.63.0. - [Release notes](https://github.com/prometheus/common/releases) - [Changelog](https://github.com/prometheus/common/blob/main/RELEASE.md) - [Commits](https://github.com/prometheus/common/compare/v0.62.0...v0.63.0) --- updated-dependencies: - dependency-name: github.com/prometheus/common dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2c03b032..9d1c9b8b 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/onsi/gomega v1.36.2 github.com/prometheus/client_golang v1.21.1 github.com/prometheus/client_model v0.6.1 - github.com/prometheus/common v0.62.0 + github.com/prometheus/common v0.63.0 github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 diff --git a/go.sum b/go.sum index 6829248f..6a871e9a 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,8 @@ github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGC github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= +github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= +github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= From 3c6afd6cfdbffaabf109794988f57f990121f6a0 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Mon, 17 Mar 2025 23:33:49 +0000 Subject: [PATCH 118/260] Refactor beforeSuite in integration tests (#508) --- pkg/epp/server/controller_manager.go | 14 +-- pkg/epp/util/testing/wrappers.go | 11 ++ test/integration/epp/hermetic_test.go | 35 +++++- test/integration/epp/test_suite.go | 162 ++++++++------------------ 4 files changed, 98 insertions(+), 124 deletions(-) diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index 46694f7b..05b11a2b 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -28,7 +28,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -39,9 +38,9 @@ func init() { utilruntime.Must(v1alpha2.AddToScheme(scheme)) } -// NewDefaultManager creates a new controller manager with default configuration. -func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Manager, error) { - defaultOpts := ctrl.Options{ +// DefaultManagerOptions returns the default options used to create the manager. +func DefaultManagerOptions(namespace, name string) ctrl.Options { + return ctrl.Options{ Scheme: scheme, Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ @@ -67,12 +66,11 @@ func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Ma }, }, } - return NewManagerWithOptions(restConfig, defaultOpts) } -// NewManagerWithOptions creates a new controller manager with injectable options. -func NewManagerWithOptions(restConfig *rest.Config, opts manager.Options) (ctrl.Manager, error) { - manager, err := ctrl.NewManager(restConfig, opts) +// NewDefaultManager creates a new controller manager with default configuration. +func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Manager, error) { + manager, err := ctrl.NewManager(restConfig, DefaultManagerOptions(namespace, name)) if err != nil { return nil, fmt.Errorf("failed to create controller manager: %v", err) } diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go index ed57d01f..130f017e 100644 --- a/pkg/epp/util/testing/wrappers.go +++ b/pkg/epp/util/testing/wrappers.go @@ -71,6 +71,17 @@ func (p *PodWrapper) Labels(labels map[string]string) *PodWrapper { return p } +// Labels sets the pod labels. +func (p *PodWrapper) LabelsFromPoolSelector(selector map[v1alpha2.LabelKey]v1alpha2.LabelValue) *PodWrapper { + if p.ObjectMeta.Labels == nil { + p.ObjectMeta.Labels = map[string]string{} + } + for k, v := range selector { + p.ObjectMeta.Labels[string(k)] = string(v) + } + return p +} + // SetReadyCondition sets a PodReay=true condition. func (p *PodWrapper) ReadyCondition() *PodWrapper { p.Status.Conditions = []corev1.PodCondition{{ diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index d02c9c13..5a3109e1 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -31,11 +31,42 @@ import ( "google.golang.org/protobuf/types/known/structpb" "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) +var models = []*v1alpha2.InferenceModel{ + utiltesting.MakeInferenceModel("sample"). + Namespace(pool.Namespace). + ModelName("sql-lora"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + TargetModel("sql-lora-1fdg2"). + ObjRef(), + utiltesting.MakeInferenceModel("sheddable"). + Namespace(pool.Namespace). + ModelName("sql-lora-sheddable"). + Criticality(v1alpha2.Sheddable). + PoolName(pool.Name). + TargetModel("sql-lora-1fdg3"). + ObjRef(), + utiltesting.MakeInferenceModel("generic"). + Namespace(pool.Namespace). + ModelName("my-model"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + TargetModel("my-model-12345"). + ObjRef(), + utiltesting.MakeInferenceModel("direct-model"). + Namespace(pool.Namespace). + ModelName("direct-model"). + Criticality(v1alpha2.Critical). + PoolName(pool.Name). + ObjRef(), +} + func TestMain(m *testing.M) { cleanup := BeforeSuite() code := m.Run() @@ -304,7 +335,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, test.pods, false) + client, cleanup := startEPPServer(t, &eppOptions{podMetrics: test.pods, models: models}) t.Cleanup(cleanup) want := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestBody{ @@ -1336,7 +1367,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, test.pods, true) + client, cleanup := startEPPServer(t, &eppOptions{podMetrics: test.pods, models: models, streamed: true}) t.Cleanup(cleanup) responses, err := streamedRequest(t, client, test.requests, len(test.wantResponses)) diff --git a/test/integration/epp/test_suite.go b/test/integration/epp/test_suite.go index b63a6775..c02fca52 100644 --- a/test/integration/epp/test_suite.go +++ b/test/integration/epp/test_suite.go @@ -34,8 +34,6 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/types/known/structpb" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -43,8 +41,6 @@ import ( "k8s.io/component-base/metrics/legacyregistry" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/envtest" @@ -54,45 +50,49 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) const ( - port = runserver.DefaultGrpcPort + port = server.DefaultGrpcPort metricsPort = 8888 ) var ( - serverRunner *runserver.ExtProcServerRunner + serverRunner *server.ExtProcServerRunner k8sClient k8sclient.Client testEnv *envtest.Environment scheme = runtime.NewScheme() logger = logutil.NewTestLogger().V(logutil.VERBOSE) + pool = utiltesting.MakeInferencePool("vllm-llama2-7b-pool"). + Namespace("default"). + TargetPortNumber(8000). + Selector(map[string]string{"app": "vllm-llama2-7b-pool"}). + ExtensionRef("epp"). + ObjRef() ) -func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +type eppOptions struct { + podMetrics map[backendmetrics.Pod]*backendmetrics.Metrics + models []*v1alpha2.InferenceModel + streamed bool +} + +func startEPPServer(t *testing.T, opts *eppOptions) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { // Reconfigure the TestPodMetricsClient. res := map[types.NamespacedName]*backendmetrics.Metrics{} - for pod, metrics := range podAndMetrics { + for pod, metrics := range opts.podMetrics { res[pod.NamespacedName] = metrics } serverRunner.TestPodMetricsClient.SetRes(res) - serverRunner.UseStreaming = streamed - - serverCtx, stopServer := context.WithCancel(context.Background()) + serverRunner.UseStreaming = opts.streamed - // TODO: this should be consistent with the inference pool - podLabels := map[string]string{ - "app": "vllm-llama2-7b-pool", - } - - for pod := range podAndMetrics { + for pod := range opts.podMetrics { pod := utiltesting.MakePod(pod.NamespacedName.Name). Namespace(pod.NamespacedName.Namespace). ReadyCondition(). - Labels(podLabels). + LabelsFromPoolSelector(pool.Spec.Selector). IP(pod.Address). Complete(). ObjRef() @@ -108,6 +108,16 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac logutil.Fatal(logger, err, "Failed to update pod status", "pod", pod) } } + + for i := range opts.models { + m := opts.models[i].DeepCopy() + logger.Info("Creating inference model", "model", m.Name) + if err := k8sClient.Create(context.Background(), m); err != nil { + logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", m.Name) + } + } + + serverCtx, stopServer := context.WithCancel(context.Background()) go func() { if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { logutil.Fatal(logger, err, "Failed to start ext-proc server") @@ -116,7 +126,7 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac // check if all pods are synced to datastore assert.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podAndMetrics), "Datastore not synced") + assert.Len(t, serverRunner.Datastore.PodGetAll(), len(opts.podMetrics), "Datastore not synced") }, 10*time.Second, time.Second) address := fmt.Sprintf("localhost:%v", port) @@ -137,7 +147,7 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac stopServer() // clear created pods - for pod := range podAndMetrics { + for pod := range opts.podMetrics { pod := utiltesting.MakePod(pod.NamespacedName.Name). Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() @@ -145,6 +155,11 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac logutil.Fatal(logger, err, "Failed to delete pod", "pod", fakePod) } } + for _, m := range opts.models { + if err := k8sClient.Delete(context.Background(), m); err != nil { + logutil.Fatal(logger, err, "Failed to delete model", "model", m.Name) + } + } // wait a little until the goroutines actually exit time.Sleep(5 * time.Second) } @@ -175,14 +190,15 @@ func BeforeSuite() func() { k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) if err != nil { logutil.Fatal(logger, err, "Failed to start k8s Client") - } else if k8sClient == nil { - logutil.Fatal(logger, nil, "No error, but returned kubernetes client is nil", "config", cfg) } // Init runtime. ctrl.SetLogger(logger) - - mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama2-7b-pool")) + // inject options that allow multiple test runs to run + // https://github.com/kubernetes-sigs/controller-runtime/issues/2937 + opts := server.DefaultManagerOptions(pool.Namespace, pool.Name) + opts.Controller = config.Controller{SkipNameValidation: ptr.To(true)} + mgr, err := ctrl.NewManager(cfg, opts) if err != nil { logutil.Fatal(logger, err, "Failed to create controller manager") } @@ -191,80 +207,32 @@ func BeforeSuite() func() { logutil.Fatal(logger, err, "Failed to register metrics handler") } - serverRunner = runserver.NewDefaultExtProcServerRunner() + serverRunner = server.NewDefaultExtProcServerRunner() serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) // Adjust from defaults - serverRunner.PoolName = "vllm-llama2-7b-pool" + serverRunner.PoolName = pool.Name serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) serverRunner.SecureServing = false - if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { + ctx := ctrl.SetupSignalHandler() + if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { logutil.Fatal(logger, err, "Failed to setup server runner") } // Start the controller manager in a go routine, not blocking go func() { - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { logutil.Fatal(logger, err, "Failed to start manager") } }() logger.Info("Setting up hermetic ExtProc server") - ns := "default" - pool := utiltesting.MakeInferencePool("vllm-llama2-7b-pool"). - Namespace(ns). - TargetPortNumber(8000). - Selector(map[string]string{"app": "vllm-llama2-7b-pool"}). - ExtensionRef("epp"). - ObjRef() if err := k8sClient.Create(context.Background(), pool); err != nil { logutil.Fatal(logger, err, "Unable to create inferencePool", "pool", pool.Name) } - models := []*v1alpha2.InferenceModel{ - utiltesting.MakeInferenceModel("sample"). - Namespace(ns). - ModelName("sql-lora"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - TargetModel("sql-lora-1fdg2"). - ObjRef(), - utiltesting.MakeInferenceModel("sheddable"). - Namespace(ns). - ModelName("sql-lora-sheddable"). - Criticality(v1alpha2.Sheddable). - PoolName(pool.Name). - TargetModel("sql-lora-1fdg3"). - ObjRef(), - utiltesting.MakeInferenceModel("generic"). - Namespace(ns). - ModelName("my-model"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - TargetModel("my-model-12345"). - ObjRef(), - utiltesting.MakeInferenceModel("direct-model"). - Namespace(ns). - ModelName("direct-model"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - ObjRef(), - } - for i := range models { - logger.Info("Creating inference model", "model", models[i]) - if err := k8sClient.Create(context.Background(), models[i]); err != nil { - logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", models[i].Name) - } - } - - assert.Eventually(nil, func() bool { - modelExist := serverRunner.Datastore.ModelGet("my-model") - synced := serverRunner.Datastore.PoolHasSynced() && modelExist != nil - return synced - }, 10*time.Second, 10*time.Millisecond) - return func() { _ = testEnv.Stop() _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferencePool{}) @@ -329,11 +297,11 @@ func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessCli func makeMetadata(endpoint string) *structpb.Struct { return &structpb.Struct{ Fields: map[string]*structpb.Value{ - runserver.DefaultDestinationEndpointHintMetadataNamespace: { + server.DefaultDestinationEndpointHintMetadataNamespace: { Kind: &structpb.Value_StructValue{ StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ - runserver.DefaultDestinationEndpointHintKey: { + server.DefaultDestinationEndpointHintKey: { Kind: &structpb.Value_StringValue{ StringValue: endpoint, }, @@ -373,37 +341,3 @@ func registerMetricsHandler(mgr manager.Manager, port int) error { } return nil } - -// inject options that allow multiple test runs to run -// https://github.com/kubernetes-sigs/controller-runtime/issues/2937 -func managerTestOptions(namespace, name string) ctrl.Options { - return ctrl.Options{ - Scheme: scheme, - Cache: cache.Options{ - ByObject: map[client.Object]cache.ByObject{ - &corev1.Pod{}: { - Namespaces: map[string]cache.Config{ - namespace: {}, - }, - }, - &v1alpha2.InferencePool{}: { - Namespaces: map[string]cache.Config{ - namespace: { - FieldSelector: fields.SelectorFromSet(fields.Set{ - "metadata.name": name, - }), - }, - }, - }, - &v1alpha2.InferenceModel{}: { - Namespaces: map[string]cache.Config{ - namespace: {}, - }, - }, - }, - }, - Controller: config.Controller{ - SkipNameValidation: ptr.To(true), - }, - } -} From 7fbef9e59ecba4fe727385b8e0faabc183e163c3 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Tue, 18 Mar 2025 02:29:49 +0000 Subject: [PATCH 119/260] Split the extension policy since it is envoy specific (#524) * split the extension policy since it is envoy specific * merge extenstion and patch policy in one manifests --- config/manifests/gateway/patch_policy.yaml | 34 +++++++++++++++++++++- config/manifests/inferencepool.yaml | 33 --------------------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/config/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml index 3c36ed7a..d293bc82 100644 --- a/config/manifests/gateway/patch_policy.yaml +++ b/config/manifests/gateway/patch_policy.yaml @@ -85,4 +85,36 @@ spec: # op: replace # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_header_mode" # value: SEND - +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: ext-proc-policy + namespace: default +spec: + extProc: + - backendRefs: + - group: "" + kind: Service + name: vllm-llama2-7b-epp + port: 9002 + processingMode: + allowModeOverride: true + request: + body: Buffered + response: + # The timeouts are likely not needed here. We can experiment with removing/tuning them slowly. + # The connection limits are more important and will cause the opaque: ext_proc_gRPC_error_14 error in Envoy GW if not configured correctly. + messageTimeout: 1000s + backendSettings: + circuitBreaker: + maxConnections: 40000 + maxPendingRequests: 40000 + maxParallelRequests: 40000 + timeout: + tcp: + connectTimeout: 24h + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: llm-route diff --git a/config/manifests/inferencepool.yaml b/config/manifests/inferencepool.yaml index 8225bd7c..64008639 100644 --- a/config/manifests/inferencepool.yaml +++ b/config/manifests/inferencepool.yaml @@ -75,39 +75,6 @@ spec: initialDelaySeconds: 5 periodSeconds: 10 --- -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: EnvoyExtensionPolicy -metadata: - name: ext-proc-policy - namespace: default -spec: - extProc: - - backendRefs: - - group: "" - kind: Service - name: vllm-llama2-7b-epp - port: 9002 - processingMode: - allowModeOverride: true - request: - body: Buffered - response: - # The timeouts are likely not needed here. We can experiment with removing/tuning them slowly. - # The connection limits are more important and will cause the opaque: ext_proc_gRPC_error_14 error in Envoy GW if not configured correctly. - messageTimeout: 1000s - backendSettings: - circuitBreaker: - maxConnections: 40000 - maxPendingRequests: 40000 - maxParallelRequests: 40000 - timeout: - tcp: - connectTimeout: 24h - targetRef: - group: gateway.networking.k8s.io - kind: HTTPRoute - name: llm-route ---- kind: ClusterRole apiVersion: rbac.authorization.k8s.io/v1 metadata: From 950e036435d93487e243b1351780cb50a9db629f Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Tue, 18 Mar 2025 14:13:50 -0700 Subject: [PATCH 120/260] Uses tabs for quickstart model server options (#527) Signed-off-by: Daneyon Hansen --- mkdocs.yml | 3 +++ site-src/guides/index.md | 38 +++++++++++++++++++------------------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 8cd3f3fb..b157fccb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,9 @@ markdown_extensions: - toc: permalink: true - tables + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true nav: - Overview: - Introduction: index.md diff --git a/site-src/guides/index.md b/site-src/guides/index.md index d721e73f..34cb0a65 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -14,34 +14,34 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Deploy Sample Model Server - This quickstart guide contains two options for setting up model server: - + Two options are supported for running the model server: + 1. GPU-based model server. Requirements: a Hugging Face access token that grants access to the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). - + 1. CPU-based model server (not using GPUs). Requirements: a Hugging Face access token that grants access to the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). Choose one of these options and follow the steps below. Please do not deploy both, as the deployments have the same name and will override each other. - -#### GPU-Based Model Server - For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/gpu-deployment.yaml` as needed. - Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. - Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. - ```bash - kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml - ``` +=== "GPU-Based Model Server" -#### CPU-Based Model Server + For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/gpu-deployment.yaml` as needed. + Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. + Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. + ```bash + kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml + ``` - Create a Hugging Face secret to download the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). Ensure that the token grants access to this model. - Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. - ```bash - kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Qwen - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml - ``` +=== "CPU-Based Model Server" + + Create a Hugging Face secret to download the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). Ensure that the token grants access to this model. + Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. + ```bash + kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Qwen + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml + ``` ### Install the Inference Extension CRDs From 64ba0c60ee0ad1edd9337771acea4d6f6f2b4129 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 18 Mar 2025 14:43:49 -0700 Subject: [PATCH 121/260] Add instructions to run benchmarks (#480) * Add instructions to run benchmarks * Address comments * Move benchmark guide to site-src and other cleanups * Add source code link for the benchmark tool image * Address nit --- benchmark/README.md | 1 + benchmark/benchmark.ipynb | 358 ++++++++++++++++++ benchmark/download-benchmark-results.bash | 30 ++ benchmark/requirements.txt | 3 + config/manifests/benchmark/benchmark.yaml | 60 +++ .../benchmark/model-server-service.yaml | 12 + mkdocs.yml | 2 + .../benchmark/example-bar-chart.png | Bin 0 -> 61054 bytes site-src/performance/benchmark/index.md | 98 +++++ 9 files changed, 564 insertions(+) create mode 100644 benchmark/README.md create mode 100644 benchmark/benchmark.ipynb create mode 100755 benchmark/download-benchmark-results.bash create mode 100644 benchmark/requirements.txt create mode 100644 config/manifests/benchmark/benchmark.yaml create mode 100644 config/manifests/benchmark/model-server-service.yaml create mode 100644 site-src/performance/benchmark/example-bar-chart.png create mode 100644 site-src/performance/benchmark/index.md diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..ffd3ee7b --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1 @@ +This folder contains resources to run performance benchmarks. Pls follow the benchmark guide here https://gateway-api-inference-extension.sigs.k8s.io/performance/benchmark. \ No newline at end of file diff --git a/benchmark/benchmark.ipynb b/benchmark/benchmark.ipynb new file mode 100644 index 00000000..993279cb --- /dev/null +++ b/benchmark/benchmark.ipynb @@ -0,0 +1,358 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "executionInfo": { + "elapsed": 391, + "status": "ok", + "timestamp": 1741734317446, + "user": { + "displayName": "Cong Liu", + "userId": "18222691451061354557" + }, + "user_tz": 420 + }, + "id": "ziJD5zt0c1Rt" + }, + "outputs": [], + "source": [ + "#@title Configuration. Edit this before running the rest.\n", + "\n", + "OUTPUT_DIR='output'\n", + "RUN_ID='example-run'\n", + "# Path to the benchmark dir under `gateway-api-inference-extension/benchmark`\n", + "BENCHMARK_DIR =\"./\"\n", + "# A regex to match the model name, which matches the output file name.\n", + "MODEL_MATCHER='.*llama.*'" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "executionInfo": { + "elapsed": 33, + "status": "ok", + "timestamp": 1741735749209, + "user": { + "displayName": "Cong Liu", + "userId": "18222691451061354557" + }, + "user_tz": 420 + }, + "id": "dB7xALgLawN-" + }, + "outputs": [], + "source": [ + "#@title Plot Helper\n", + "import os\n", + "import pandas as pd\n", + "import re\n", + "import json\n", + "from collections import OrderedDict\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import math\n", + "import logging\n", + "level = logging.INFO\n", + "logger = logging.getLogger(__name__)\n", + "logger.setLevel(level)\n", + "handler = logging.StreamHandler() # This sends output to the console\n", + "handler.setLevel(level) # Set handler level\n", + "logger.addHandler(handler)\n", + "\n", + "title_fontsize = 18\n", + "axis_label_fontsize = 18\n", + "legend_fontsize = 16\n", + "tick_label_fontsize = 14\n", + "\n", + "# Encapsulates some basic information needed to plot metrics.\n", + "class XY:\n", + " def __init__(self, x: str, y: str, x_label=None, y_label=None):\n", + " self.x = x\n", + " self.y = y\n", + " self.x_label = x if x_label is None else x_label\n", + " self.y_label = y if y_label is None else y_label\n", + "\n", + "NUM_PLOTS_PER_ROW = 4\n", + "# The arguments need to match the metric name fields generated by the benchmark tool.\n", + "CORE_METRICS = [\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'output_tokens_per_min'),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y = \"p90_per_output_token_latency\"),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y = \"p90_latency\"),\n", + "]\n", + "SANITY_CHECK_METRICS = [\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'benchmark_time'),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_attempted\"),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_succeeded\"),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'throughput_rps'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'total_input_tokens'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'total_output_token'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'avg_input_len'),\n", + " XY(x = 'request_rate', x_label = 'QPS', y = 'avg_output_len'),\n", + "]\n", + "\n", + "class Label:\n", + " def __init__(self, name, alias=None):\n", + " self.name = name\n", + " self.alias = name if alias is None else alias\n", + "\n", + "ALL_METRICS = CORE_METRICS + SANITY_CHECK_METRICS\n", + "\n", + "class Plotter:\n", + " def __init__(self, run_id, labels=None, metrics=CORE_METRICS, num_plots_per_row=5, interactive=False, annotate=False, output_dir=OUTPUT_DIR):\n", + " self.run_id = run_id\n", + " self.labels = labels\n", + " self.metrics = metrics\n", + " self.num_plots_per_row = num_plots_per_row\n", + " self.interactive = interactive\n", + " self.annotate = annotate\n", + " self.output_dir = output_dir\n", + "\n", + " def withRunId(self, run_id):\n", + " return Plotter(run_id, self.labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", + "\n", + " def withLabels(self, labels):\n", + " return Plotter(self.run_id, labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", + "\n", + " def withMetrics(self, metrics):\n", + " return Plotter(self.run_id, self.labels, metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", + "\n", + " def withOutputDir(self, output_dir):\n", + " return Plotter(self.run_id, self.labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, output_dir)\n", + "\n", + " def plot_bar(self):\n", + " data = load_data(self.labels, self.run_id, self.output_dir)\n", + " groups = group_data(data, self.metrics)\n", + " logger.debug(\"Plotting run id...\")\n", + " plot_bar(self.labels, groups, self.metrics, self.num_plots_per_row, self.interactive, annotate=self.annotate)\n", + "\n", + "def filepaths(root_dir):\n", + " \"\"\"\n", + " Recursively reads files within a directory and returns a list of file paths.\n", + " \"\"\"\n", + "\n", + " filepaths = []\n", + " for dirpath, dirnames, filenames in os.walk(root_dir):\n", + " for filename in filenames:\n", + " filepath = os.path.join(dirpath, filename)\n", + " filepaths.append(filepath)\n", + " return filepaths\n", + "\n", + "def flatten_server_metrics(server_metrics):\n", + " \"\"\"\n", + " Flattens the server metrics json to a single level.\n", + " \"\"\"\n", + " flattend = {}\n", + " for k, v in server_metrics.items():\n", + " if isinstance(v, dict):\n", + " for k2, v2 in v.items():\n", + " flattend[k + \".\" + k2] = v2\n", + "\n", + " return flattend\n", + "\n", + "def load_data(labels, run_id, output_dir=OUTPUT_DIR):\n", + " data_path =f\"{BENCHMARK_DIR}/{output_dir}/{run_id}\"\n", + " records = []\n", + " logger.debug(f\"Loading data for {data_path}\")\n", + " for file in filepaths(data_path):\n", + " for label in labels:\n", + " regex = f\".*\\/{label.name}\\/results/json/{MODEL_MATCHER}.json\"\n", + " logger.debug(f\"matching file {file} for regex {regex} and label {label}\")\n", + " if re.match(regex, file):\n", + " logger.debug(f\"found match file {file} for regex {regex} and label {label}\")\n", + " with open(file, 'r') as f:\n", + " raw_data = json.load(f)\n", + " sample_data = {\n", + " 'file_name': f.name,\n", + " 'label': label.alias,\n", + " **raw_data.get(\"metrics\",{}),\n", + " **flatten_server_metrics(raw_data.get(\"metrics\",{}).get(\"server_metrics\", {})),\n", + " }\n", + " sample_data['request_rate'] = sample_data['request_rate'] * raw_data['config']['num_models']\n", + " records.append(sample_data)\n", + " all_data = pd.DataFrame.from_records(records, index='file_name') if len(records) > 0 else pd.DataFrame()\n", + " return all_data\n", + "\n", + "def group_data(all_data, metrics=CORE_METRICS):\n", + " try:\n", + " data = all_data.sort_values(by=['request_rate'], ascending=True).copy().dropna()\n", + " except:\n", + " # print(\"No data found\")\n", + " return None\n", + "\n", + " # Ensure there is exactly one benchmark result per label and x-axis for each\n", + " # metric.\n", + " x_axes = set()\n", + " for m in metrics:\n", + " x_axes.add(m.x)\n", + "\n", + " for x in x_axes:\n", + " sizes = data.groupby(by=['label', x], dropna=True).size()\n", + " for index, v in sizes.items():\n", + " if v > 1:\n", + " label, _ = index\n", + " # print(f\"Multiple benchmark results for the same label ({label}), and x-axis ({x}). {index}: {v}. Please use more selective file filters.\")\n", + " # raise ValueError(f\"Multiple benchmark results for the same label ({label}), and x-axis ({x}). Please use more selective file filters.\")\n", + "\n", + " # Group by label.\n", + " groups = data.groupby(by=['label'],sort=True)\n", + " return groups\n", + "\n", + "def init_plot(metrics, num_plots_per_row=NUM_PLOTS_PER_ROW):\n", + " num_plots_per_row = min(num_plots_per_row, len(metrics))\n", + " row_count = math.ceil(len(metrics) / num_plots_per_row)\n", + " fig, axes = plt.subplots(nrows=row_count, ncols=num_plots_per_row, figsize=(20, 5*row_count), tight_layout=True)\n", + " if row_count == 1 and num_plots_per_row == 1:\n", + " axes = [axes]\n", + " return fig, axes\n", + "\n", + "def plot_metrics(metrics, plot_func, num_plots_per_row=NUM_PLOTS_PER_ROW, fig=None, axes=None):\n", + " \"\"\"\n", + " plot_func: a function in the form of def plot_func(ax:~matplotlib.axes.Axes , m: XY):\n", + " \"\"\"\n", + " logger.debug(f'Plotting metrics: {metrics}')\n", + " num_plots_per_row = min(num_plots_per_row, len(metrics))\n", + " if fig is None or axes is None:\n", + " logger.debug(f'Creating new figure and axes')\n", + " fig, axes = init_plot(metrics, num_plots_per_row)\n", + " row_count = math.ceil(len(metrics) / num_plots_per_row)\n", + " for i, m in enumerate(metrics):\n", + " row = math.floor(i/num_plots_per_row)\n", + " col = i%num_plots_per_row\n", + " if row_count == 1:\n", + " curAx = axes[col]\n", + " else:\n", + " curAx = axes[row, col]\n", + " plot_func(curAx, m)\n", + " return fig, axes\n", + "\n", + "def plot_bar(labels, groups, metrics=CORE_METRICS, num_plots_per_row=NUM_PLOTS_PER_ROW, interactive=INTERACTIVE_PLOT, annotate=False):\n", + " labels = [label.alias for label in labels]\n", + " logger.debug(f'Prnting bar chart for {labels}')\n", + " logger.debug(f'groups: {groups}')\n", + " dataframes = []\n", + " for label in labels:\n", + " try:\n", + " dataframes.append(groups.get_group((label,)))\n", + " except:\n", + " logger.debug(f\"No data found for label {label}\")\n", + " continue\n", + " y_columns = [m.y for m in metrics]\n", + " logger.debug(f'y_columns: {y_columns}')\n", + " logger.debug(f'dataframes: {dataframes}')\n", + "\n", + " # 1. Combine all request rates\n", + " all_request_rates = set()\n", + " for df in dataframes:\n", + " all_request_rates.update(df['request_rate'].astype(int))\n", + " all_request_rates = sorted(list(all_request_rates))\n", + "\n", + " # 2. Prepare data for plotting: Create a nested dictionary\n", + " plot_data = {y_col: {label: {} for label in labels} for y_col in y_columns}\n", + "\n", + " for i, df in enumerate(dataframes):\n", + " label = labels[i]\n", + " df_dict = df.set_index('request_rate').to_dict()\n", + " for y_col in y_columns:\n", + " for request_rate in all_request_rates:\n", + " plot_data[y_col][label][request_rate] = df_dict.get(y_col, {}).get(request_rate, np.nan)\n", + "\n", + " logger.debug(f'Plot_data: {plot_data}')\n", + "\n", + " # 3. Plotting\n", + " def plot_func(curAx, m):\n", + " num_request_rates = len(all_request_rates)\n", + " num_labels = len(labels)\n", + " x = np.arange(num_request_rates) # the label locations (x-axis positions)\n", + " width = 0.4 / num_labels # width of the bars\n", + "\n", + " for i, label in enumerate(labels):\n", + " bar_x = x - (width*num_labels)/2 + i*width + width/2\n", + " #Extract y-values to plot\n", + " y_values = [plot_data[m.y][label][rr] for rr in all_request_rates]\n", + "\n", + " rects = curAx.bar(bar_x, y_values, width, label=label)\n", + " if annotate:\n", + " for rect, val in zip(rects, y_values):\n", + " if not np.isnan(val):\n", + " height = rect.get_height()\n", + " curAx.annotate(f'{val:.2f}',\n", + " xy=(rect.get_x() + rect.get_width() / 2, height),\n", + " xytext=(0, 3), # 3 points vertical offset\n", + " textcoords=\"offset points\",\n", + " ha='center', va='bottom')\n", + " # Add labels, title, and legend\n", + " curAx.set_xlabel(m.x_label, fontsize=axis_label_fontsize)\n", + " curAx.set_ylabel(m.y_label, fontsize=axis_label_fontsize)\n", + " curAx.set_xticks(x)\n", + " curAx.set_xticklabels(all_request_rates)\n", + " curAx.tick_params(axis='both', labelsize=tick_label_fontsize)\n", + " curAx.legend(fontsize=legend_fontsize, loc='upper left', frameon=True, framealpha=0.8, edgecolor='black')\n", + " fig, axes = plot_metrics(metrics, plot_func, num_plots_per_row)\n", + " fig.tight_layout(rect=[0, 0.03, 1, 0.95])\n", + " plt.show()\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "height": 1000 + }, + "executionInfo": { + "elapsed": 2232, + "status": "ok", + "timestamp": 1741735855456, + "user": { + "displayName": "Cong Liu", + "userId": "18222691451061354557" + }, + "user_tz": 420 + }, + "id": "HbGEAOucb_Jn", + "outputId": "faf0304b-92f4-4fa7-ae71-83b8bd987e70" + }, + "outputs": [], + "source": [ + "#@title Plot Result\n", + "\n", + "pl = Plotter(run_id=RUN_ID, labels=[Label('inference-extension'),Label('k8s-svc')], output_dir=OUTPUT_DIR)\n", + "pl.plot_bar()" + ] + } + ], + "metadata": { + "colab": { + "last_runtime": { + "build_target": "", + "kind": "local" + }, + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/benchmark/download-benchmark-results.bash b/benchmark/download-benchmark-results.bash new file mode 100755 index 00000000..333fc6cc --- /dev/null +++ b/benchmark/download-benchmark-results.bash @@ -0,0 +1,30 @@ +#!/bin/bash + +# Downloads the benchmark result files from the benchmark tool pod. +download_benchmark_results() { + until echo $(kubectl logs deployment/benchmark-tool -n ${namespace}) | grep -q -m 1 "LPG_FINISHED"; do sleep 30 ; done; + benchmark_pod=$(kubectl get pods -l app=benchmark-tool -n ${namespace} -o jsonpath="{.items[0].metadata.name}") + echo "Downloading JSON results from pod ${benchmark_pod}" + kubectl exec ${benchmark_pod} -n ${namespace} -- rm -f ShareGPT_V3_unfiltered_cleaned_split.json + for f in $(kubectl exec ${benchmark_pod} -n ${namespace} -- /bin/sh -c ls -l | grep json); do + echo "Downloading json file ${f}" + kubectl cp -n ${namespace} ${benchmark_pod}:$f ${benchmark_output_dir}/results/json/$f; + done +} + +# Env vars to be passed when calling this script. +# The id of the benchmark. This is needed to identify what the benchmark is for. +# It decides the filepath to save the results, which later is used by the jupyter notebook to assign +# the benchmark_id as data labels for plotting. +benchmark_id=${benchmark_id:-"inference-extension"} +# run_id can be used to group different runs of the same benchmarks for comparison. +run_id=${run_id:-"default-run"} +namespace=${namespace:-"default"} +output_dir=${output_dir:-'output'} + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +benchmark_output_dir=${SCRIPT_DIR}/${output_dir}/${run_id}/${benchmark_id} + +echo "Saving benchmark results to ${benchmark_output_dir}/results/json/" +download_benchmark_results +kubectl delete -f ${SCRIPT_DIR}/../config/manifests/benchmark/benchmark.yaml \ No newline at end of file diff --git a/benchmark/requirements.txt b/benchmark/requirements.txt new file mode 100644 index 00000000..44974cf4 --- /dev/null +++ b/benchmark/requirements.txt @@ -0,0 +1,3 @@ +pandas +numpy +matplotlib \ No newline at end of file diff --git a/config/manifests/benchmark/benchmark.yaml b/config/manifests/benchmark/benchmark.yaml new file mode 100644 index 00000000..a47b4617 --- /dev/null +++ b/config/manifests/benchmark/benchmark.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: benchmark-tool + name: benchmark-tool +spec: + replicas: 1 + selector: + matchLabels: + app: benchmark-tool + template: + metadata: + labels: + app: benchmark-tool + spec: + containers: + # The following image was built from this source https://github.com/AI-Hypercomputer/inference-benchmark/tree/07628c9fe01b748f5a4cc9e5c2ee4234aaf47699 + - image: 'us-docker.pkg.dev/cloud-tpu-images/inference/inference-benchmark@sha256:1c100b0cc949c7df7a2db814ae349c790f034b4b373aaad145e77e815e838438' + imagePullPolicy: Always + name: benchmark-tool + command: + - bash + - -c + - ./latency_throughput_curve.sh + env: + - name: IP + value: '' + - name: REQUEST_RATES + value: '10,20,30' + - name: BENCHMARK_TIME_SECONDS + value: '60' + - name: TOKENIZER + value: 'meta-llama/Llama-2-7b-hf' + - name: MODELS + value: 'meta-llama/Llama-2-7b-hf' + - name: BACKEND + value: vllm + - name: PORT + value: "8081" + - name: INPUT_LENGTH + value: "1024" + - name: OUTPUT_LENGTH + value: '2048' + - name: FILE_PREFIX + value: benchmark + - name: PROMPT_DATASET_FILE + value: ShareGPT_V3_unfiltered_cleaned_split.json + - name: HF_TOKEN + valueFrom: + secretKeyRef: + key: token + name: hf-token + resources: + limits: + cpu: "2" + memory: 20Gi + requests: + cpu: "2" + memory: 20Gi diff --git a/config/manifests/benchmark/model-server-service.yaml b/config/manifests/benchmark/model-server-service.yaml new file mode 100644 index 00000000..014054cf --- /dev/null +++ b/config/manifests/benchmark/model-server-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-pool-service +spec: + ports: + - port: 8081 + protocol: TCP + targetPort: 8000 + selector: + app: my-pool + type: LoadBalancer diff --git a/mkdocs.yml b/mkdocs.yml index b157fccb..2dc4d2a1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,8 @@ nav: - Adapter Rollout: guides/adapter-rollout.md - Metrics: guides/metrics.md - Implementer's Guide: guides/implementers.md + - Performance: + - Benchmark: performance/benchmark/index.md - Reference: - API Reference: reference/spec.md - API Types: diff --git a/site-src/performance/benchmark/example-bar-chart.png b/site-src/performance/benchmark/example-bar-chart.png new file mode 100644 index 0000000000000000000000000000000000000000..54dc65898cfe352efa7f3e87d5215f77d3ad0dc6 GIT binary patch literal 61054 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 in `./config/manifests/benchmark/benchmark.yaml` to your target IP. Feel free to adjust other parameters such as request_rates as well. For a complete list of LPG configurations, pls refer to the [LPG user guide](https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark). + +1. Start the benchmark tool. `kubectl apply -f ./config/manifests/benchmark/benchmark.yaml` + +1. Wait for benchmark to finish and download the results. Use the `benchmark_id` environment variable +to specify what this benchmark is for. For instance, `inference-extension` or `k8s-svc`. When the LPG tool finishes benchmarking, it will print a log line `LPG_FINISHED`, +the script below will watch for that log line and then start downloading results. + + ```bash + benchmark_id='my-benchmark' ./benchmark/download-benchmark-results.bash + ``` + +1. After the script finishes, you should see benchmark results under `./benchmark/output/default-run/my-benchmark/results/json` folder. + +### Tips + +* You can specify `run_id="runX"` environment variable when running the `./download-benchmark-results.bash` script. +This is useful when you run benchmarks multiple times to get a more statistically meaningful results and group the results accordingly. +* Update the `request_rates` that best suit your benchmark environment. + +### Advanced Benchmark Configurations + +Pls refer to the [LPG user guide](https://github.com/AI-Hypercomputer/inference-benchmark?tab=readme-ov-file#configuring-the-benchmark) for a detailed list of configuration knobs. + +## Analyze the results + +This guide shows how to run the jupyter notebook using vscode. + +1. Create a python virtual environment. + + ```bash + python3 -m venv .venv + source .venv/bin/activate + ``` + +1. Install the dependencies. + + ```bash + pip install -r ./benchmark/requirements.txt + ``` + +1. Open the notebook `./benchmark/benchmark.ipynb`, and run each cell. At the end you should + see a bar chart like below: + + ![alt text](example-bar-chart.png) \ No newline at end of file From e9264f25ab0d15ed8bd23b8645411e6cf687f56e Mon Sep 17 00:00:00 2001 From: Kuromesi Date: Wed, 19 Mar 2025 12:23:49 +0800 Subject: [PATCH 122/260] add helm template (#416) * initialize helm template Signed-off-by: Kuromesi * tidy template Signed-off-by: Kuromesi * nit and add inference pool Signed-off-by: Kuromesi * relocate Signed-off-by: Kuromesi * fix Signed-off-by: Kuromesi * fix * add readme Signed-off-by: Kuromesi * nit Signed-off-by: Kuromesi * Apply suggestions from code review --------- Signed-off-by: Kuromesi Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> --- config/charts/inferencepool/.helmignore | 23 +++++ config/charts/inferencepool/Chart.yaml | 9 ++ config/charts/inferencepool/README.md | 45 ++++++++++ .../charts/inferencepool/templates/NOTES.txt | 1 + .../inferencepool/templates/_helpers.tpl | 24 +++++ .../templates/inferencepool.yaml | 89 +++++++++++++++++++ .../charts/inferencepool/templates/rbac.yaml | 45 ++++++++++ config/charts/inferencepool/values.yaml | 14 +++ 8 files changed, 250 insertions(+) create mode 100644 config/charts/inferencepool/.helmignore create mode 100644 config/charts/inferencepool/Chart.yaml create mode 100644 config/charts/inferencepool/README.md create mode 100644 config/charts/inferencepool/templates/NOTES.txt create mode 100644 config/charts/inferencepool/templates/_helpers.tpl create mode 100644 config/charts/inferencepool/templates/inferencepool.yaml create mode 100644 config/charts/inferencepool/templates/rbac.yaml create mode 100644 config/charts/inferencepool/values.yaml diff --git a/config/charts/inferencepool/.helmignore b/config/charts/inferencepool/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/config/charts/inferencepool/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/config/charts/inferencepool/Chart.yaml b/config/charts/inferencepool/Chart.yaml new file mode 100644 index 00000000..5e46737c --- /dev/null +++ b/config/charts/inferencepool/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: InferencePool +description: A Helm chart for InferencePool + +type: application + +version: 0.1.0 + +appVersion: "0.2.0" diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md new file mode 100644 index 00000000..ee0481d3 --- /dev/null +++ b/config/charts/inferencepool/README.md @@ -0,0 +1,45 @@ +# InferencePool + +A chart to deploy an InferencePool and a corresponding EndpointPicker (epp) deployment. + + +## Install + +To install an InferencePool named `pool-1` that selects from endpoints with label `app: vllm-llama2-7b` and listening on port `8000`, you can run the following command: + +```txt +$ helm install pool-1 ./config/charts/inferencepool \ + --set inferencePool.name=pool-1 \ + --set inferencePool.selector.app=vllm-llama2-7b \ + --set inferencePool.targetPortNumber=8000 +``` + +where `inferencePool.targetPortNumber` is the pod that vllm backends served on and `inferencePool.selector` is the selector to match the vllm backends. + +## Uninstall + +Run the following command to uninstall the chart: + +```txt +$ helm uninstall pool-1 +``` + +## Configuration + +The following table list the configurable parameters of the chart. + +| **Parameter Name** | **Description** | +|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| `inferencePool.name` | Name for the InferencePool, and inference extension will be named as `${inferencePool.name}-epp`. | +| `inferencePool.targetPortNumber` | Target port number for the vllm backends, will be used to scrape metrics by the inference extension. | +| `inferencePool.selector` | Label selector to match vllm backends managed by the inference pool. | +| `inferenceExtension.replicas` | Number of replicas for the inference extension service. Defaults to `1`. | +| `inferenceExtension.image.name` | Name of the container image used for the inference extension. | +| `inferenceExtension.image.hub` | Registry URL where the inference extension image is hosted. | +| `inferenceExtension.image.tag` | Image tag of the inference extension. | +| `inferenceExtension.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | +| `inferenceExtension.extProcPort` | Port where the inference extension service is served for external processing. Defaults to `9002`. | + +## Notes + +This chart will only deploy an InferencePool and its corresponding EndpointPicker extension. Before install the chart, please make sure that the inference extension CRDs are installed in the cluster. For more details, please refer to the [getting started guide](https://gateway-api-inference-extension.sigs.k8s.io/guides/). diff --git a/config/charts/inferencepool/templates/NOTES.txt b/config/charts/inferencepool/templates/NOTES.txt new file mode 100644 index 00000000..3d822165 --- /dev/null +++ b/config/charts/inferencepool/templates/NOTES.txt @@ -0,0 +1 @@ +InferencePool {{ .Values.inferencePool.name }} deployed. diff --git a/config/charts/inferencepool/templates/_helpers.tpl b/config/charts/inferencepool/templates/_helpers.tpl new file mode 100644 index 00000000..bb15f9e4 --- /dev/null +++ b/config/charts/inferencepool/templates/_helpers.tpl @@ -0,0 +1,24 @@ +{{/* +Common labels +*/}} +{{- define "gateway-api-inference-extension.labels" -}} +app.kubernetes.io/name: {{ include "gateway-api-inference-extension.name" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +{{- end }} + +{{/* +Inference extension name +*/}} +{{- define "gateway-api-inference-extension.name" -}} +{{- $base := .Values.inferencePool.name | default "default-pool" | lower | trim | trunc 40 -}} +{{ $base }}-epp +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "gateway-api-inference-extension.selectorLabels" -}} +app: {{ include "gateway-api-inference-extension.name" . }} +{{- end -}} diff --git a/config/charts/inferencepool/templates/inferencepool.yaml b/config/charts/inferencepool/templates/inferencepool.yaml new file mode 100644 index 00000000..8fc97496 --- /dev/null +++ b/config/charts/inferencepool/templates/inferencepool.yaml @@ -0,0 +1,89 @@ +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + name: {{ .Values.inferencePool.name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + targetPortNumber: {{ .Values.inferencePool.targetPortNumber }} + selector: + {{- range $key, $value := .Values.inferencePool.selector }} + {{ $key }}: {{ quote $value }} + {{- end }} + extensionRef: + name: {{ include "gateway-api-inference-extension.name" . }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.inferenceExtension.replicas | default 1 }} + selector: + matchLabels: + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "gateway-api-inference-extension.name" . }} + containers: + - name: epp + image: {{ .Values.inferenceExtension.image.hub }}/{{ .Values.inferenceExtension.image.name }}:{{ .Values.inferenceExtension.image.tag }} + imagePullPolicy: {{ .Values.inferenceExtension.image.pullPolicy | default "Always" }} + args: + - -poolName + - {{ .Values.inferencePool.name }} + - -poolNamespace + - {{ .Release.Namespace }} + - -v + - "3" + - -grpcPort + - "9002" + - -grpcHealthPort + - "9003" + - -metricsPort + - "9090" + ports: + - name: grpc + containerPort: 9002 + - name: grpc-health + containerPort: 9003 + - name: metrics + containerPort: 9090 + livenessProbe: + grpc: + port: 9003 + service: inference-extension + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + grpc: + port: 9003 + service: inference-extension + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + selector: + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 4 }} + ports: + - name: grpc-ext-proc + protocol: TCP + port: {{ .Values.inferenceExtension.extProcPort | default 9002 }} + - name: http-metrics + protocol: TCP + port: {{ .Values.inferenceExtension.metricsPort | default 9090 }} + type: ClusterIP diff --git a/config/charts/inferencepool/templates/rbac.yaml b/config/charts/inferencepool/templates/rbac.yaml new file mode 100644 index 00000000..7a98e820 --- /dev/null +++ b/config/charts/inferencepool/templates/rbac.yaml @@ -0,0 +1,45 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +rules: +- apiGroups: ["inference.networking.x-k8s.io"] + resources: ["inferencemodels, inferencepools"] + verbs: ["get", "watch", "list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"] +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} +subjects: +- kind: ServiceAccount + name: {{ include "gateway-api-inference-extension.name" . }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ include "gateway-api-inference-extension.name" . }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml new file mode 100644 index 00000000..7d3e868d --- /dev/null +++ b/config/charts/inferencepool/values.yaml @@ -0,0 +1,14 @@ +inferenceExtension: + replicas: 1 + image: + name: epp + hub: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension + tag: main + pullPolicy: Always + extProcPort: 9002 + +inferencePool: + name: pool-1 + targetPortNumber: 8000 + selector: + app: vllm-llama2-7b From f5a91e5f219bd83e807f28b9781b917d94c88140 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 19 Mar 2025 15:50:33 +0200 Subject: [PATCH 123/260] bump vllm-cpu image to latest (#530) Signed-off-by: Nir Rozenbaum --- config/manifests/vllm/cpu-deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml index 5ca20d1a..a3912a1f 100644 --- a/config/manifests/vllm/cpu-deployment.yaml +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -14,7 +14,7 @@ spec: spec: containers: - name: lora - image: "public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo:v0.7.2" # formal images can be found in https://gallery.ecr.aws/q9t5s3a7/vllm-cpu-release-repo + image: "public.ecr.aws/q9t5s3a7/vllm-cpu-release-repo:v0.8.0" # formal images can be found in https://gallery.ecr.aws/q9t5s3a7/vllm-cpu-release-repo imagePullPolicy: Always command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] args: From ad29b488bb768522b1448ac2046c160819cec17e Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 19 Mar 2025 16:30:32 +0200 Subject: [PATCH 124/260] removed hf token from cpu based example (#464) * removed hf token from cpu based example Signed-off-by: Nir Rozenbaum * added limits to cpu deployment Signed-off-by: Nir Rozenbaum * fixed a typo Signed-off-by: Nir Rozenbaum * updated LoRA adapters Signed-off-by: Nir Rozenbaum * documentation cpu platform Signed-off-by: Nir Rozenbaum * rebase Signed-off-by: Nir Rozenbaum * updated config map and lora syncer init container Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- config/manifests/vllm/cpu-deployment.yaml | 64 ++++++++++++++--------- site-src/guides/index.md | 15 ++++-- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml index a3912a1f..6ac1014c 100644 --- a/config/manifests/vllm/cpu-deployment.yaml +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -26,16 +26,11 @@ spec: - "--max-loras" - "4" - "--lora-modules" - - '{"name": "tweet-summary-0", "path": "/adapters/ai-blond/Qwen-Qwen2.5-Coder-1.5B-Instruct-lora_0"}' - - '{"name": "tweet-summary-1", "path": "/adapters/ai-blond/Qwen-Qwen2.5-Coder-1.5B-Instruct-lora_1"}' + - '{"name": "tweet-summary-0", "path": "SriSanth2345/Qwen-1.5B-Tweet-Generations", "base_model_name": "Qwen/Qwen2.5-1.5B"}' + - '{"name": "tweet-summary-1", "path": "SriSanth2345/Qwen-1.5B-Tweet-Generations", "base_model_name": "Qwen/Qwen2.5-1.5B"}' env: - name: PORT value: "8000" - - name: HUGGING_FACE_HUB_TOKEN - valueFrom: - secretKeyRef: - name: hf-token - key: token - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING value: "true" - name: VLLM_CPU_KVCACHE_SPACE @@ -64,6 +59,13 @@ spec: periodSeconds: 5 successThreshold: 1 timeoutSeconds: 1 + resources: + limits: + cpu: "12" + memory: "9000Mi" + requests: + cpu: "12" + memory: "9000Mi" volumeMounts: - mountPath: /data name: data @@ -72,26 +74,18 @@ spec: - name: adapters mountPath: "/adapters" initContainers: - - name: adapter-loader - image: ghcr.io/tomatillo-and-multiverse/adapter-puller:demo - command: ["python"] - args: - - ./pull_adapters.py - - --adapter - - ai-blond/Qwen-Qwen2.5-Coder-1.5B-Instruct-lora - - --duplicate-count - - "4" + - name: lora-adapter-syncer + tty: true + stdin: true + image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/lora-syncer:main + restartPolicy: Always + imagePullPolicy: Always env: - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: hf-token - key: token - - name: HF_HOME - value: /adapters - volumeMounts: - - name: adapters - mountPath: "/adapters" + - name: DYNAMIC_LORA_ROLLOUT_CONFIG + value: "/config/configmap.yaml" + volumeMounts: # DO NOT USE subPath, dynamic configmap updates don't work on subPaths + - name: config-volume + mountPath: /config restartPolicy: Always schedulerName: default-scheduler terminationGracePeriodSeconds: 30 @@ -103,3 +97,21 @@ spec: medium: Memory - name: adapters emptyDir: {} + - name: config-volume + configMap: + name: vllm-qwen-adapters +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-qwen-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama2-7b + port: 8000 + ensureExist: + models: + - base-model: Qwen/Qwen2.5-1.5B + id: tweet-summary-1 + source: SriSanth2345/Qwen-1.5B-Tweet-Generations \ No newline at end of file diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 34cb0a65..bcea5f9b 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -5,7 +5,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ## **Prerequisites** - Envoy Gateway [v1.3.0](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - A cluster with: - - Support for services of typs `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). + - Support for services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - Support for [sidecar containers](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/) (enabled by default since Kubernetes v1.29) to run the model server deployment. @@ -20,7 +20,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv Requirements: a Hugging Face access token that grants access to the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). 1. CPU-based model server (not using GPUs). - Requirements: a Hugging Face access token that grants access to the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). + The sample uses the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). Choose one of these options and follow the steps below. Please do not deploy both, as the deployments have the same name and will override each other. @@ -28,6 +28,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/gpu-deployment.yaml` as needed. Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. + Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 @@ -36,10 +37,16 @@ This quickstart guide is intended for engineers familiar with k8s and model serv === "CPU-Based Model Server" - Create a Hugging Face secret to download the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). Ensure that the token grants access to this model. + This setup is using the formal `vllm-cpu` image, which according to the documentation can run vLLM on x86 CPU platform. + For this setup, we use approximately 9.5GB of memory and 12 CPUs for each replica. + While it is possible to deploy the model server with less resources, this is not recommended. + For example, in our tests, loading the model using 8GB of memory and 1 CPU was possible but took almost 3.5 minutes and inference requests took unreasonable time. + In general, there is a tradeoff between the memory and CPU we allocate to our pods and the performance. The more memory and CPU we allocate the better performance we can get. + After running multiple configurations of these values we decided in this sample to use 9.5GB of memory and 12 CPUs for each replica, which gives reasonable response times. You can increase those numbers and potentially may even get better response times. + For modifying the allocated resources, adjust the numbers in `./config/manifests/vllm/cpu-deployment.yaml` as needed. + Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash - kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Qwen kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml ``` From bf3ec69d11246529002c7d41afb09353a77bc07e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 08:42:31 -0700 Subject: [PATCH 125/260] Bump golang.org/x/net from 0.35.0 to 0.36.0 (#529) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/net/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9d1c9b8b..49b5608e 100644 --- a/go.mod +++ b/go.mod @@ -103,10 +103,10 @@ require ( go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/crypto v0.33.0 // indirect + golang.org/x/crypto v0.35.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.23.0 // indirect - golang.org/x/net v0.35.0 // indirect + golang.org/x/net v0.36.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect diff --git a/go.sum b/go.sum index 6a871e9a..816a5525 100644 --- a/go.sum +++ b/go.sum @@ -222,8 +222,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -234,8 +234,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From dc5f7aa57a2eddd1532af69c1f6181e6cb5eab8e Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Wed, 19 Mar 2025 09:42:34 -0700 Subject: [PATCH 126/260] Move benchmark under tools (#534) --- site-src/performance/benchmark/index.md | 8 ++++---- {benchmark => tools/benchmark}/README.md | 0 {benchmark => tools/benchmark}/benchmark.ipynb | 0 .../benchmark}/download-benchmark-results.bash | 2 +- {benchmark => tools/benchmark}/requirements.txt | 0 5 files changed, 5 insertions(+), 5 deletions(-) rename {benchmark => tools/benchmark}/README.md (100%) rename {benchmark => tools/benchmark}/benchmark.ipynb (100%) rename {benchmark => tools/benchmark}/download-benchmark-results.bash (95%) rename {benchmark => tools/benchmark}/requirements.txt (100%) diff --git a/site-src/performance/benchmark/index.md b/site-src/performance/benchmark/index.md index 445729a6..e612c49d 100644 --- a/site-src/performance/benchmark/index.md +++ b/site-src/performance/benchmark/index.md @@ -60,10 +60,10 @@ to specify what this benchmark is for. For instance, `inference-extension` or `k the script below will watch for that log line and then start downloading results. ```bash - benchmark_id='my-benchmark' ./benchmark/download-benchmark-results.bash + benchmark_id='my-benchmark' ./tools/benchmark/download-benchmark-results.bash ``` -1. After the script finishes, you should see benchmark results under `./benchmark/output/default-run/my-benchmark/results/json` folder. +1. After the script finishes, you should see benchmark results under `./tools/benchmark/output/default-run/my-benchmark/results/json` folder. ### Tips @@ -89,10 +89,10 @@ This guide shows how to run the jupyter notebook using vscode. 1. Install the dependencies. ```bash - pip install -r ./benchmark/requirements.txt + pip install -r ./tools/benchmark/requirements.txt ``` -1. Open the notebook `./benchmark/benchmark.ipynb`, and run each cell. At the end you should +1. Open the notebook `./tools/benchmark/benchmark.ipynb`, and run each cell. At the end you should see a bar chart like below: ![alt text](example-bar-chart.png) \ No newline at end of file diff --git a/benchmark/README.md b/tools/benchmark/README.md similarity index 100% rename from benchmark/README.md rename to tools/benchmark/README.md diff --git a/benchmark/benchmark.ipynb b/tools/benchmark/benchmark.ipynb similarity index 100% rename from benchmark/benchmark.ipynb rename to tools/benchmark/benchmark.ipynb diff --git a/benchmark/download-benchmark-results.bash b/tools/benchmark/download-benchmark-results.bash similarity index 95% rename from benchmark/download-benchmark-results.bash rename to tools/benchmark/download-benchmark-results.bash index 333fc6cc..6b9ca505 100755 --- a/benchmark/download-benchmark-results.bash +++ b/tools/benchmark/download-benchmark-results.bash @@ -27,4 +27,4 @@ benchmark_output_dir=${SCRIPT_DIR}/${output_dir}/${run_id}/${benchmark_id} echo "Saving benchmark results to ${benchmark_output_dir}/results/json/" download_benchmark_results -kubectl delete -f ${SCRIPT_DIR}/../config/manifests/benchmark/benchmark.yaml \ No newline at end of file +kubectl delete -f ${SCRIPT_DIR}/../../config/manifests/benchmark/benchmark.yaml \ No newline at end of file diff --git a/benchmark/requirements.txt b/tools/benchmark/requirements.txt similarity index 100% rename from benchmark/requirements.txt rename to tools/benchmark/requirements.txt From 296247b07feed430458b8e0e3f496055a88f5e89 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:58:33 +0000 Subject: [PATCH 127/260] fixed rbac in helm chart (#531) --- config/charts/inferencepool/templates/rbac.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/charts/inferencepool/templates/rbac.yaml b/config/charts/inferencepool/templates/rbac.yaml index 7a98e820..cdd50c6a 100644 --- a/config/charts/inferencepool/templates/rbac.yaml +++ b/config/charts/inferencepool/templates/rbac.yaml @@ -6,7 +6,7 @@ metadata: {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} rules: - apiGroups: ["inference.networking.x-k8s.io"] - resources: ["inferencemodels, inferencepools"] + resources: ["inferencemodels", "inferencepools"] verbs: ["get", "watch", "list"] - apiGroups: [""] resources: ["pods"] From 079236c7be6f5d45571a58ba2370044a09c270c9 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Wed, 19 Mar 2025 14:32:31 -0400 Subject: [PATCH 128/260] Support full duplex streaming (#463) --- cmd/body-based-routing/main.go | 6 +- pkg/body-based-routing/handlers/request.go | 127 +++++++++---- .../handlers/request_test.go | 172 ++++++++++++------ pkg/body-based-routing/handlers/response.go | 30 +-- pkg/body-based-routing/handlers/server.go | 84 +++++++-- pkg/body-based-routing/server/runserver.go | 16 +- test/integration/bbr/hermetic_test.go | 6 +- 7 files changed, 319 insertions(+), 122 deletions(-) diff --git a/cmd/body-based-routing/main.go b/cmd/body-based-routing/main.go index 13f841b6..cfc584ce 100644 --- a/cmd/body-based-routing/main.go +++ b/cmd/body-based-routing/main.go @@ -44,7 +44,7 @@ import ( var ( grpcPort = flag.Int( "grpcPort", - runserver.DefaultGrpcPort, + 9004, "The gRPC port used for communicating with Envoy proxy") grpcHealthPort = flag.Int( "grpcHealthPort", @@ -52,6 +52,8 @@ var ( "The port used for gRPC liveness and readiness probes") metricsPort = flag.Int( "metricsPort", 9090, "The metrics port") + streaming = flag.Bool( + "streaming", false, "Enables streaming support for Envoy full-duplex streaming mode") logVerbosity = flag.Int("v", logging.DEFAULT, "number for the log level verbosity") setupLog = ctrl.Log.WithName("setup") @@ -92,7 +94,7 @@ func run() error { ctx := ctrl.SetupSignalHandler() // Setup runner. - serverRunner := &runserver.ExtProcServerRunner{GrpcPort: *grpcPort} + serverRunner := runserver.NewDefaultExtProcServerRunner(*grpcPort, *streaming) // Register health server. if err := registerHealthServer(mgr, ctrl.Log.WithName("health"), *grpcHealthPort); err != nil { diff --git a/pkg/body-based-routing/handlers/request.go b/pkg/body-based-routing/handlers/request.go index 6596e191..c0be46ac 100644 --- a/pkg/body-based-routing/handlers/request.go +++ b/pkg/body-based-routing/handlers/request.go @@ -23,17 +23,21 @@ import ( basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" eppb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +const modelHeader = "X-Gateway-Model-Name" + // HandleRequestBody handles request bodies. -func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*eppb.ProcessingResponse, error) { +func (s *Server) HandleRequestBody(ctx context.Context, data map[string]any) ([]*eppb.ProcessingResponse, error) { logger := log.FromContext(ctx) + var ret []*eppb.ProcessingResponse - var data map[string]any - if err := json.Unmarshal(body.GetBody(), &data); err != nil { + requestBodyBytes, err := json.Marshal(data) + if err != nil { return nil, err } @@ -41,37 +45,71 @@ func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*e if !ok { metrics.RecordModelNotInBodyCounter() logger.V(logutil.DEFAULT).Info("Request body does not contain model parameter") - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_RequestBody{ - RequestBody: &eppb.BodyResponse{}, - }, - }, nil + if s.streaming { + ret = append(ret, &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &eppb.HeadersResponse{}, + }, + }) + ret = addStreamedBodyResponse(ret, requestBodyBytes) + return ret, nil + } else { + ret = append(ret, &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestBody{ + RequestBody: &eppb.BodyResponse{}, + }, + }) + } + return ret, nil } modelStr, ok := modelVal.(string) if !ok { metrics.RecordModelNotParsedCounter() logger.V(logutil.DEFAULT).Info("Model parameter value is not a string") - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_RequestBody{ - RequestBody: &eppb.BodyResponse{}, - }, - }, fmt.Errorf("the model parameter value %v is not a string", modelVal) + return nil, fmt.Errorf("the model parameter value %v is not a string", modelVal) } metrics.RecordSuccessCounter() - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_RequestBody{ - RequestBody: &eppb.BodyResponse{ - Response: &eppb.CommonResponse{ - // Necessary so that the new headers are used in the routing decision. - ClearRouteCache: true, - HeaderMutation: &eppb.HeaderMutation{ - SetHeaders: []*basepb.HeaderValueOption{ - { - Header: &basepb.HeaderValue{ - Key: "X-Gateway-Model-Name", - RawValue: []byte(modelStr), + + if s.streaming { + ret = append(ret, &eppb.ProcessingResponse{ + Response: &eppb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &eppb.HeadersResponse{ + Response: &eppb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &eppb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: modelHeader, + RawValue: []byte(modelStr), + }, + }, + }, + }, + }, + }, + }, + }) + ret = addStreamedBodyResponse(ret, requestBodyBytes) + return ret, nil + } + + return []*eppb.ProcessingResponse{ + { + Response: &eppb.ProcessingResponse_RequestBody{ + RequestBody: &eppb.BodyResponse{ + Response: &eppb.CommonResponse{ + // Necessary so that the new headers are used in the routing decision. + ClearRouteCache: true, + HeaderMutation: &eppb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: modelHeader, + RawValue: []byte(modelStr), + }, }, }, }, @@ -82,20 +120,43 @@ func (s *Server) HandleRequestBody(ctx context.Context, body *eppb.HttpBody) (*e }, nil } +func addStreamedBodyResponse(responses []*eppb.ProcessingResponse, requestBodyBytes []byte) []*eppb.ProcessingResponse { + return append(responses, &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: requestBodyBytes, + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }) +} + // HandleRequestHeaders handles request headers. -func (s *Server) HandleRequestHeaders(headers *eppb.HttpHeaders) (*eppb.ProcessingResponse, error) { - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_RequestHeaders{ - RequestHeaders: &eppb.HeadersResponse{}, +func (s *Server) HandleRequestHeaders(headers *eppb.HttpHeaders) ([]*eppb.ProcessingResponse, error) { + return []*eppb.ProcessingResponse{ + { + Response: &eppb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &eppb.HeadersResponse{}, + }, }, }, nil } // HandleRequestTrailers handles request trailers. -func (s *Server) HandleRequestTrailers(trailers *eppb.HttpTrailers) (*eppb.ProcessingResponse, error) { - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_RequestTrailers{ - RequestTrailers: &eppb.TrailersResponse{}, +func (s *Server) HandleRequestTrailers(trailers *eppb.HttpTrailers) ([]*eppb.ProcessingResponse, error) { + return []*eppb.ProcessingResponse{ + { + Response: &eppb.ProcessingResponse_RequestTrailers{ + RequestTrailers: &eppb.TrailersResponse{}, + }, }, }, nil } diff --git a/pkg/body-based-routing/handlers/request_test.go b/pkg/body-based-routing/handlers/request_test.go index 76f64e0c..0f088702 100644 --- a/pkg/body-based-routing/handlers/request_test.go +++ b/pkg/body-based-routing/handlers/request_test.go @@ -18,6 +18,7 @@ package handlers import ( "context" + "encoding/json" "strings" "testing" @@ -31,78 +32,138 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -const ( - bodyWithModel = ` - { - "model": "foo", - "prompt": "Tell me a joke" - } - ` - bodyWithModelNoStr = ` - { - "model": 1, - "prompt": "Tell me a joke" - } - ` - bodyWithoutModel = ` - { - "prompt": "Tell me a joke" - } - ` -) - func TestHandleRequestBody(t *testing.T) { metrics.Register() ctx := logutil.NewTestLoggerIntoContext(context.Background()) tests := []struct { - name string - body *extProcPb.HttpBody - want *extProcPb.ProcessingResponse - wantErr bool + name string + body map[string]any + streaming bool + want []*extProcPb.ProcessingResponse + wantErr bool }{ { - name: "malformed body", - body: &extProcPb.HttpBody{ - Body: []byte("malformed json"), + name: "model not found", + body: map[string]any{ + "prompt": "Tell me a joke", + }, + want: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{}, + }, + }, }, - wantErr: true, }, { - name: "model not found", - body: &extProcPb.HttpBody{ - Body: []byte(bodyWithoutModel), + name: "model not found with streaming", + body: map[string]any{ + "prompt": "Tell me a joke", }, - want: &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{}, + streaming: true, + want: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{}, + }, + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: mapToBytes(t, map[string]any{ + "prompt": "Tell me a joke", + }), + EndOfStream: true, + }, + }, + }, + }, + }, + }, }, }, }, { name: "model is not string", - body: &extProcPb.HttpBody{ - Body: []byte(bodyWithModelNoStr), + body: map[string]any{ + "model": 1, + "prompt": "Tell me a joke", }, wantErr: true, }, { name: "success", - body: &extProcPb.HttpBody{ - Body: []byte(bodyWithModel), + body: map[string]any{ + "model": "foo", + "prompt": "Tell me a joke", }, - want: &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - // Necessary so that the new headers are used in the routing decision. - ClearRouteCache: true, - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: []*basepb.HeaderValueOption{ - { - Header: &basepb.HeaderValue{ - Key: "X-Gateway-Model-Name", - RawValue: []byte("foo"), + want: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + // Necessary so that the new headers are used in the routing decision. + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte("foo"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "success-with-streaming", + body: map[string]any{ + "model": "foo", + "prompt": "Tell me a joke", + }, + streaming: true, + want: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte("foo"), + }, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: mapToBytes(t, map[string]any{ + "model": "foo", + "prompt": "Tell me a joke", + }), + EndOfStream: true, }, }, }, @@ -116,7 +177,7 @@ func TestHandleRequestBody(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - server := &Server{} + server := &Server{streaming: test.streaming} resp, err := server.HandleRequestBody(ctx, test.body) if err != nil { if !test.wantErr { @@ -147,3 +208,12 @@ func TestHandleRequestBody(t *testing.T) { t.Error(err) } } + +func mapToBytes(t *testing.T, m map[string]any) []byte { + // Convert map to JSON byte array + bytes, err := json.Marshal(m) + if err != nil { + t.Fatalf("Marshal(): %v", err) + } + return bytes +} diff --git a/pkg/body-based-routing/handlers/response.go b/pkg/body-based-routing/handlers/response.go index a62aa076..fbcb75d6 100644 --- a/pkg/body-based-routing/handlers/response.go +++ b/pkg/body-based-routing/handlers/response.go @@ -21,28 +21,34 @@ import ( ) // HandleResponseHeaders handles response headers. -func (s *Server) HandleResponseHeaders(headers *eppb.HttpHeaders) (*eppb.ProcessingResponse, error) { - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_ResponseHeaders{ - ResponseHeaders: &eppb.HeadersResponse{}, +func (s *Server) HandleResponseHeaders(headers *eppb.HttpHeaders) ([]*eppb.ProcessingResponse, error) { + return []*eppb.ProcessingResponse{ + { + Response: &eppb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &eppb.HeadersResponse{}, + }, }, }, nil } // HandleResponseBody handles response bodies. -func (s *Server) HandleResponseBody(body *eppb.HttpBody) (*eppb.ProcessingResponse, error) { - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_ResponseBody{ - ResponseBody: &eppb.BodyResponse{}, +func (s *Server) HandleResponseBody(body *eppb.HttpBody) ([]*eppb.ProcessingResponse, error) { + return []*eppb.ProcessingResponse{ + { + Response: &eppb.ProcessingResponse_ResponseBody{ + ResponseBody: &eppb.BodyResponse{}, + }, }, }, nil } // HandleResponseTrailers handles response trailers. -func (s *Server) HandleResponseTrailers(trailers *eppb.HttpTrailers) (*eppb.ProcessingResponse, error) { - return &eppb.ProcessingResponse{ - Response: &eppb.ProcessingResponse_ResponseTrailers{ - ResponseTrailers: &eppb.TrailersResponse{}, +func (s *Server) HandleResponseTrailers(trailers *eppb.HttpTrailers) ([]*eppb.ProcessingResponse, error) { + return []*eppb.ProcessingResponse{ + { + Response: &eppb.ProcessingResponse_ResponseTrailers{ + ResponseTrailers: &eppb.TrailersResponse{}, + }, }, }, nil } diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/body-based-routing/handlers/server.go index 813c55c8..36eb3c2f 100644 --- a/pkg/body-based-routing/handlers/server.go +++ b/pkg/body-based-routing/handlers/server.go @@ -18,23 +18,27 @@ package handlers import ( "context" + "encoding/json" "errors" "io" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/go-logr/logr" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -func NewServer() *Server { - return &Server{} +func NewServer(streaming bool) *Server { + return &Server{streaming: streaming} } // Server implements the Envoy external processing server. // https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor.proto -type Server struct{} +type Server struct { + streaming bool +} func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { ctx := srv.Context() @@ -42,6 +46,8 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { loggerVerbose := logger.V(logutil.VERBOSE) loggerVerbose.Info("Processing") + reader, writer := io.Pipe() + for { select { case <-ctx.Done(): @@ -60,19 +66,25 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { return status.Errorf(codes.Unknown, "cannot receive stream request: %v", recvErr) } - var resp *extProcPb.ProcessingResponse + var responses []*extProcPb.ProcessingResponse var err error switch v := req.Request.(type) { case *extProcPb.ProcessingRequest_RequestHeaders: - resp, err = s.HandleRequestHeaders(req.GetRequestHeaders()) + if s.streaming && !req.GetRequestHeaders().GetEndOfStream() { + // If streaming and the body is not empty, then headers are handled when processing request body. + loggerVerbose.Info("Received headers, passing off header processing until body arrives...") + } else { + responses, err = s.HandleRequestHeaders(req.GetRequestHeaders()) + } case *extProcPb.ProcessingRequest_RequestBody: - resp, err = s.HandleRequestBody(ctx, req.GetRequestBody()) + loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) + responses, err = s.processRequestBody(ctx, req.GetRequestBody(), writer, reader, logger) case *extProcPb.ProcessingRequest_RequestTrailers: - resp, err = s.HandleRequestTrailers(req.GetRequestTrailers()) + responses, err = s.HandleRequestTrailers(req.GetRequestTrailers()) case *extProcPb.ProcessingRequest_ResponseHeaders: - resp, err = s.HandleResponseHeaders(req.GetResponseHeaders()) + responses, err = s.HandleResponseHeaders(req.GetResponseHeaders()) case *extProcPb.ProcessingRequest_ResponseBody: - resp, err = s.HandleResponseBody(req.GetResponseBody()) + responses, err = s.HandleResponseBody(req.GetResponseBody()) default: logger.V(logutil.DEFAULT).Error(nil, "Unknown Request type", "request", v) return status.Error(codes.Unknown, "unknown request type") @@ -83,10 +95,56 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { return status.Errorf(status.Code(err), "failed to handle request: %v", err) } - loggerVerbose.Info("Response generated", "response", resp) - if err := srv.Send(resp); err != nil { - logger.V(logutil.DEFAULT).Error(err, "Send failed") - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + for _, resp := range responses { + loggerVerbose.Info("Response generated", "response", resp) + if err := srv.Send(resp); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Send failed") + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } } } } + +func (s *Server) processRequestBody(ctx context.Context, body *extProcPb.HttpBody, bufferWriter *io.PipeWriter, bufferReader *io.PipeReader, logger logr.Logger) ([]*extProcPb.ProcessingResponse, error) { + loggerVerbose := logger.V(logutil.VERBOSE) + + var requestBody map[string]interface{} + if s.streaming { + // In the stream case, we can receive multiple request bodies. + // To buffer the full message, we create a goroutine with a writer.Write() + // call, which will block until the corresponding reader reads from it. + // We do not read until we receive the EndofStream signal, and then + // decode the entire JSON body. + if !body.EndOfStream { + go func() { + loggerVerbose.Info("Writing to stream buffer") + _, err := bufferWriter.Write(body.Body) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error populating writer") + } + }() + + return nil, nil + } + + if body.EndOfStream { + loggerVerbose.Info("Flushing stream buffer") + decoder := json.NewDecoder(bufferReader) + if err := decoder.Decode(&requestBody); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + } + bufferReader.Close() + } + } else { + if err := json.Unmarshal(body.GetBody(), &requestBody); err != nil { + return nil, err + } + } + + requestBodyResp, err := s.HandleRequestBody(ctx, requestBody) + if err != nil { + return nil, err + } + + return requestBodyResp, nil +} diff --git a/pkg/body-based-routing/server/runserver.go b/pkg/body-based-routing/server/runserver.go index 90a64b70..1646aa5a 100644 --- a/pkg/body-based-routing/server/runserver.go +++ b/pkg/body-based-routing/server/runserver.go @@ -34,17 +34,14 @@ import ( type ExtProcServerRunner struct { GrpcPort int SecureServing bool + Streaming bool } -// Default values for CLI flags in main -const ( - DefaultGrpcPort = 9004 // default for --grpcPort -) - -func NewDefaultExtProcServerRunner() *ExtProcServerRunner { +func NewDefaultExtProcServerRunner(port int, streaming bool) *ExtProcServerRunner { return &ExtProcServerRunner{ - GrpcPort: DefaultGrpcPort, + GrpcPort: port, SecureServing: true, + Streaming: streaming, } } @@ -65,7 +62,10 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { srv = grpc.NewServer() } - extProcPb.RegisterExternalProcessorServer(srv, handlers.NewServer()) + extProcPb.RegisterExternalProcessorServer( + srv, + handlers.NewServer(r.Streaming), + ) // Forward to the gRPC runnable. return runnable.GRPCServer("ext-proc", srv, r.GrpcPort).Start(ctx) diff --git a/test/integration/bbr/hermetic_test.go b/test/integration/bbr/hermetic_test.go index be8b2721..718bfedf 100644 --- a/test/integration/bbr/hermetic_test.go +++ b/test/integration/bbr/hermetic_test.go @@ -35,8 +35,6 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -const port = runserver.DefaultGrpcPort - var logger = logutil.NewTestLogger().V(logutil.VERBOSE) func TestBodyBasedRouting(t *testing.T) { @@ -102,8 +100,10 @@ func TestBodyBasedRouting(t *testing.T) { } func setUpHermeticServer() (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + port := 9004 + serverCtx, stopServer := context.WithCancel(context.Background()) - serverRunner := runserver.NewDefaultExtProcServerRunner() + serverRunner := runserver.NewDefaultExtProcServerRunner(port, false) serverRunner.SecureServing = false go func() { From a73776caeb7fd9fe3f676d5bb1e735f11b4dbf54 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 19 Mar 2025 12:14:31 -0700 Subject: [PATCH 129/260] simplifying EPP-side buffer (#538) --- pkg/epp/handlers/streamingserver.go | 34 +++++++---------------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 0e2fbd1c..2b471232 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -55,8 +55,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) RequestState: RequestReceived, } - reader, writer := io.Pipe() - decoder := json.NewDecoder(reader) + var body []byte var requestBody, responseBody map[string]interface{} // Create error handling var as each request should only report once for @@ -95,28 +94,18 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) case *extProcPb.ProcessingRequest_RequestBody: loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) // In the stream case, we can receive multiple request bodies. - // To buffer the full message, we create a goroutine with a writer.Write() - // call, which will block until the corresponding reader reads from it. - // We do not read until we receive the EndofStream signal, and then - // decode the entire JSON body. - go func() { - _, err := writer.Write(v.RequestBody.Body) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error populating writer") - } - }() + body = append(body, v.RequestBody.Body...) // Message is buffered, we can read and decode. if v.RequestBody.EndOfStream { loggerVerbose.Info("decoding") - err = decoder.Decode(&requestBody) + err = json.Unmarshal(body, &requestBody) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") } - // Body stream complete. Close the reader pipe, and start anew for response. - reader.Close() - reader, writer = io.Pipe() - decoder = json.NewDecoder(reader) + + // Body stream complete. Allocate empty slice for response to use. + body = []byte{} reqCtx, err = s.HandleRequestBody(ctx, reqCtx, req, requestBody) if err != nil { @@ -184,12 +173,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) }, } } else { - go func() { - _, err := writer.Write(v.ResponseBody.Body) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error populating writer") - } - }() + body = append(body, v.ResponseBody.Body...) // Message is buffered, we can read and decode. if v.ResponseBody.EndOfStream { @@ -197,12 +181,10 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. // using the standard 'err' var will send an immediate error response back to the caller. var responseErr error - responseErr = decoder.Decode(&responseBody) + responseErr = json.Unmarshal(body, &responseBody) if responseErr != nil { logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body") } - // Body stream complete. Close the reader pipe. - reader.Close() reqCtx, responseErr = s.HandleResponseBody(ctx, reqCtx, responseBody) if responseErr != nil { From e304511cd18691a0a70f7962f82f071d130788d5 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 19 Mar 2025 16:22:30 -0700 Subject: [PATCH 130/260] integration test stability improvements (#541) --- Makefile | 2 +- pkg/epp/server/controller_manager.go | 10 + test/integration/epp/hermetic_test.go | 419 ++++++++++++++++-- test/integration/epp/test_suite.go | 343 -------------- .../inferencepool-with-model-hermetic.yaml | 63 +++ 5 files changed, 462 insertions(+), 375 deletions(-) delete mode 100644 test/integration/epp/test_suite.go create mode 100644 test/testdata/inferencepool-with-model-hermetic.yaml diff --git a/Makefile b/Makefile index 0a02cb9c..e5b50319 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +124,7 @@ test: manifests generate fmt vet envtest image-build ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -race -coverprofile cover.out .PHONY: test-integration -test-integration: manifests generate fmt vet envtest ## Run tests. +test-integration: ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./test/integration/epp/... -race -coverprofile cover.out .PHONY: test-e2e diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index 05b11a2b..41fe86a9 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -28,6 +28,7 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -76,3 +77,12 @@ func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Ma } return manager, nil } + +// NewManagerWithOptions creates a new controller manager with injectable options. +func NewManagerWithOptions(restConfig *rest.Config, opts manager.Options) (ctrl.Manager, error) { + manager, err := ctrl.NewManager(restConfig, opts) + if err != nil { + return nil, fmt.Errorf("failed to create controller manager: %v", err) + } + return manager, nil +} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 5a3109e1..bb73eafc 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -18,54 +18,70 @@ limitations under the License. package epp import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" "os" + "path/filepath" "strconv" "strings" "testing" + "time" configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" + "sigs.k8s.io/yaml" ) -var models = []*v1alpha2.InferenceModel{ - utiltesting.MakeInferenceModel("sample"). - Namespace(pool.Namespace). - ModelName("sql-lora"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - TargetModel("sql-lora-1fdg2"). - ObjRef(), - utiltesting.MakeInferenceModel("sheddable"). - Namespace(pool.Namespace). - ModelName("sql-lora-sheddable"). - Criticality(v1alpha2.Sheddable). - PoolName(pool.Name). - TargetModel("sql-lora-1fdg3"). - ObjRef(), - utiltesting.MakeInferenceModel("generic"). - Namespace(pool.Namespace). - ModelName("my-model"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - TargetModel("my-model-12345"). - ObjRef(), - utiltesting.MakeInferenceModel("direct-model"). - Namespace(pool.Namespace). - ModelName("direct-model"). - Criticality(v1alpha2.Critical). - PoolName(pool.Name). - ObjRef(), -} +const ( + port = runserver.DefaultGrpcPort + metricsPort = 8888 +) + +var ( + serverRunner *runserver.ExtProcServerRunner + k8sClient k8sclient.Client + testEnv *envtest.Environment + scheme = runtime.NewScheme() + logger = logutil.NewTestLogger().V(logutil.VERBOSE) +) func TestMain(m *testing.M) { cleanup := BeforeSuite() @@ -335,7 +351,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := startEPPServer(t, &eppOptions{podMetrics: test.pods, models: models}) + client, cleanup := setUpHermeticServer(t, test.pods, false) t.Cleanup(cleanup) want := &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_RequestBody{ @@ -1367,7 +1383,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := startEPPServer(t, &eppOptions{podMetrics: test.pods, models: models, streamed: true}) + client, cleanup := setUpHermeticServer(t, test.pods, true) t.Cleanup(cleanup) responses, err := streamedRequest(t, client, test.requests, len(test.wantResponses)) @@ -1388,3 +1404,344 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }) } } + +func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { + // Reconfigure the TestPodMetricsClient. + res := map[types.NamespacedName]*backendmetrics.Metrics{} + for pod, metrics := range podAndMetrics { + res[pod.NamespacedName] = metrics + } + serverRunner.TestPodMetricsClient.SetRes(res) + serverRunner.UseStreaming = streamed + + serverCtx, stopServer := context.WithCancel(context.Background()) + + // TODO: this should be consistent with the inference pool + podLabels := map[string]string{ + "app": "vllm-llama2-7b-pool", + } + + for pod := range podAndMetrics { + pod := utiltesting.MakePod(pod.NamespacedName.Name). + Namespace(pod.NamespacedName.Namespace). + ReadyCondition(). + Labels(podLabels). + IP(pod.Address). + Complete(). + ObjRef() + + copy := pod.DeepCopy() + if err := k8sClient.Create(context.Background(), copy); err != nil { + logutil.Fatal(logger, err, "Failed to create pod", "pod", pod) + } + + // since no pod controllers deployed in fake environment, we manually update pod status + copy.Status = pod.Status + if err := k8sClient.Status().Update(context.Background(), copy); err != nil { + logutil.Fatal(logger, err, "Failed to update pod status", "pod", pod) + } + } + go func() { + if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { + logutil.Fatal(logger, err, "Failed to start ext-proc server") + } + }() + + // check if all pods are synced to datastore + assert.EventuallyWithT(t, func(t *assert.CollectT) { + assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podAndMetrics), "Datastore not synced") + }, 10*time.Second, time.Second) + + address := fmt.Sprintf("localhost:%v", port) + // Create a grpc connection + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + logutil.Fatal(logger, err, "Failed to connect", "address", address) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) + if err != nil { + logutil.Fatal(logger, err, "Failed to create client") + } + return client, func() { + cancel() + conn.Close() + stopServer() + + // clear created pods + for pod := range podAndMetrics { + pod := utiltesting.MakePod(pod.NamespacedName.Name). + Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() + + if err := k8sClient.Delete(context.Background(), pod); err != nil { + logutil.Fatal(logger, err, "Failed to delete pod", "pod", fakePod) + } + } + } +} + +func fakePod(index int) backendmetrics.Pod { + return backendmetrics.Pod{ + NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, + Address: fmt.Sprintf("192.168.1.%d", index+1), + } +} + +// Sets up a test environment and returns the runner struct +func BeforeSuite() func() { + // Set up mock k8s API Client + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + cfg, err := testEnv.Start() + if err != nil { + logutil.Fatal(logger, err, "Failed to start test environment", "config", cfg) + } + + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.AddToScheme(scheme)) + + k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) + if err != nil { + logutil.Fatal(logger, err, "Failed to start k8s Client") + } else if k8sClient == nil { + logutil.Fatal(logger, nil, "No error, but returned kubernetes client is nil", "config", cfg) + } + + // Init runtime. + ctrl.SetLogger(logger) + + mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama2-7b-pool")) + if err != nil { + logutil.Fatal(logger, err, "Failed to create controller manager") + } + + if err := registerMetricsHandler(mgr, metricsPort); err != nil { + logutil.Fatal(logger, err, "Failed to register metrics handler") + } + + serverRunner = runserver.NewDefaultExtProcServerRunner() + serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} + pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) + // Adjust from defaults + serverRunner.PoolName = "vllm-llama2-7b-pool" + serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) + serverRunner.SecureServing = false + + if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { + logutil.Fatal(logger, err, "Failed to setup server runner") + } + + // Start the controller manager in a go routine, not blocking + go func() { + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logutil.Fatal(logger, err, "Failed to start manager") + } + }() + + logger.Info("Setting up hermetic ExtProc server") + + // Unmarshal CRDs from file into structs + manifestsPath := filepath.Join("..", "..", "testdata", "inferencepool-with-model-hermetic.yaml") + docs, err := readDocuments(manifestsPath) + if err != nil { + logutil.Fatal(logger, err, "Can't read object manifests", "path", manifestsPath) + } + + for _, doc := range docs { + inferenceModel := &v1alpha2.InferenceModel{} + if err = yaml.Unmarshal(doc, inferenceModel); err != nil { + logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) + } + if inferenceModel.Kind == "InferenceModel" { + logger.Info("Creating inference model", "model", inferenceModel) + if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { + logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", inferenceModel.Name) + } + } + } + for _, doc := range docs { + inferencePool := &v1alpha2.InferencePool{} + if err = yaml.Unmarshal(doc, inferencePool); err != nil { + logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) + } + if inferencePool.Kind == "InferencePool" { + logger.Info("Creating inference pool", "pool", inferencePool) + if err := k8sClient.Create(context.Background(), inferencePool); err != nil { + logutil.Fatal(logger, err, "Unable to create inferencePool", "poolName", inferencePool.Name) + } + } + } + + assert.Eventually(nil, func() bool { + modelExist := serverRunner.Datastore.ModelGet("my-model") + synced := serverRunner.Datastore.PoolHasSynced() && modelExist != nil + return synced + }, 10*time.Second, 10*time.Millisecond) + + return func() { + _ = testEnv.Stop() + _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferencePool{}) + _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferenceModel{}) + } +} + +func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + + res, err := client.Recv() + if err != nil { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + return res, err +} + +func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { + for _, req := range requests { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + } + responses := []*extProcPb.ProcessingResponse{} + + // Make an incredible simple timeout func in the case where + // there is less than the expected amount of responses; bail and fail. + var simpleTimeout bool + go func() { + time.Sleep(10 * time.Second) + simpleTimeout = true + }() + + for range expectedResponses { + if simpleTimeout { + break + } + res, err := client.Recv() + if err != nil && err != io.EOF { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + responses = append(responses, res) + } + return responses, nil +} + +// readDocuments reads documents from file. +func readDocuments(fp string) ([][]byte, error) { + b, err := os.ReadFile(fp) + if err != nil { + return nil, err + } + + docs := [][]byte{} + reader := k8syaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(b))) + for { + // Read document + doc, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + docs = append(docs, doc) + } + return docs, nil +} + +func makeMetadata(endpoint string) *structpb.Struct { + return &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultDestinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + runserver.DefaultDestinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + }, + }, + }, + }, + } +} + +// registerMetricsHandler is a simplified version of metrics endpoint handler +// without Authentication for integration tests. +func registerMetricsHandler(mgr manager.Manager, port int) error { + metrics.Register() + + // Init HTTP server. + h := promhttp.HandlerFor( + legacyregistry.DefaultGatherer, + promhttp.HandlerOpts{}, + ) + + mux := http.NewServeMux() + mux.Handle("/metrics", h) + + srv := &http.Server{ + Addr: net.JoinHostPort("", strconv.Itoa(port)), + Handler: mux, + } + + if err := mgr.Add(&manager.Server{ + Name: "metrics", + Server: srv, + }); err != nil { + return err + } + return nil +} + +// inject options that allow multiple test runs to run +// https://github.com/kubernetes-sigs/controller-runtime/issues/2937 +func managerTestOptions(namespace, name string) ctrl.Options { + return ctrl.Options{ + Scheme: scheme, + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &corev1.Pod{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + &v1alpha2.InferencePool{}: { + Namespaces: map[string]cache.Config{ + namespace: { + FieldSelector: fields.SelectorFromSet(fields.Set{ + "metadata.name": name, + }), + }, + }, + }, + &v1alpha2.InferenceModel{}: { + Namespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + }, + }, + Controller: config.Controller{ + SkipNameValidation: boolPointer(true), + }, + } +} + +func boolPointer(b bool) *bool { + return &b +} diff --git a/test/integration/epp/test_suite.go b/test/integration/epp/test_suite.go deleted file mode 100644 index c02fca52..00000000 --- a/test/integration/epp/test_suite.go +++ /dev/null @@ -1,343 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 epp contains integration tests for the ext proc while faking the backend pods. -package epp - -import ( - "context" - "fmt" - "io" - "net" - "net/http" - "path/filepath" - "strconv" - "testing" - "time" - - extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/stretchr/testify/assert" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/protobuf/types/known/structpb" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "k8s.io/component-base/metrics/legacyregistry" - "k8s.io/utils/ptr" - ctrl "sigs.k8s.io/controller-runtime" - k8sclient "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/config" - "sigs.k8s.io/controller-runtime/pkg/envtest" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" -) - -const ( - port = server.DefaultGrpcPort - metricsPort = 8888 -) - -var ( - serverRunner *server.ExtProcServerRunner - k8sClient k8sclient.Client - testEnv *envtest.Environment - scheme = runtime.NewScheme() - logger = logutil.NewTestLogger().V(logutil.VERBOSE) - pool = utiltesting.MakeInferencePool("vllm-llama2-7b-pool"). - Namespace("default"). - TargetPortNumber(8000). - Selector(map[string]string{"app": "vllm-llama2-7b-pool"}). - ExtensionRef("epp"). - ObjRef() -) - -type eppOptions struct { - podMetrics map[backendmetrics.Pod]*backendmetrics.Metrics - models []*v1alpha2.InferenceModel - streamed bool -} - -func startEPPServer(t *testing.T, opts *eppOptions) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { - // Reconfigure the TestPodMetricsClient. - res := map[types.NamespacedName]*backendmetrics.Metrics{} - for pod, metrics := range opts.podMetrics { - res[pod.NamespacedName] = metrics - } - serverRunner.TestPodMetricsClient.SetRes(res) - serverRunner.UseStreaming = opts.streamed - - for pod := range opts.podMetrics { - pod := utiltesting.MakePod(pod.NamespacedName.Name). - Namespace(pod.NamespacedName.Namespace). - ReadyCondition(). - LabelsFromPoolSelector(pool.Spec.Selector). - IP(pod.Address). - Complete(). - ObjRef() - - copy := pod.DeepCopy() - if err := k8sClient.Create(context.Background(), copy); err != nil { - logutil.Fatal(logger, err, "Failed to create pod", "pod", pod) - } - - // since no pod controllers deployed in fake environment, we manually update pod status - copy.Status = pod.Status - if err := k8sClient.Status().Update(context.Background(), copy); err != nil { - logutil.Fatal(logger, err, "Failed to update pod status", "pod", pod) - } - } - - for i := range opts.models { - m := opts.models[i].DeepCopy() - logger.Info("Creating inference model", "model", m.Name) - if err := k8sClient.Create(context.Background(), m); err != nil { - logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", m.Name) - } - } - - serverCtx, stopServer := context.WithCancel(context.Background()) - go func() { - if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { - logutil.Fatal(logger, err, "Failed to start ext-proc server") - } - }() - - // check if all pods are synced to datastore - assert.EventuallyWithT(t, func(t *assert.CollectT) { - assert.Len(t, serverRunner.Datastore.PodGetAll(), len(opts.podMetrics), "Datastore not synced") - }, 10*time.Second, time.Second) - - address := fmt.Sprintf("localhost:%v", port) - // Create a grpc connection - conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - logutil.Fatal(logger, err, "Failed to connect", "address", address) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - client, err = extProcPb.NewExternalProcessorClient(conn).Process(ctx) - if err != nil { - logutil.Fatal(logger, err, "Failed to create client") - } - return client, func() { - cancel() - conn.Close() - stopServer() - - // clear created pods - for pod := range opts.podMetrics { - pod := utiltesting.MakePod(pod.NamespacedName.Name). - Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() - - if err := k8sClient.Delete(context.Background(), pod); err != nil { - logutil.Fatal(logger, err, "Failed to delete pod", "pod", fakePod) - } - } - for _, m := range opts.models { - if err := k8sClient.Delete(context.Background(), m); err != nil { - logutil.Fatal(logger, err, "Failed to delete model", "model", m.Name) - } - } - // wait a little until the goroutines actually exit - time.Sleep(5 * time.Second) - } -} - -func fakePod(index int) backendmetrics.Pod { - return backendmetrics.Pod{ - NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, - Address: fmt.Sprintf("192.168.1.%d", index+1), - } -} - -// Sets up a test environment and returns the runner struct -func BeforeSuite() func() { - // Set up mock k8s API Client - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - } - cfg, err := testEnv.Start() - if err != nil { - logutil.Fatal(logger, err, "Failed to start test environment", "config", cfg) - } - - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha2.AddToScheme(scheme)) - - k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) - if err != nil { - logutil.Fatal(logger, err, "Failed to start k8s Client") - } - - // Init runtime. - ctrl.SetLogger(logger) - // inject options that allow multiple test runs to run - // https://github.com/kubernetes-sigs/controller-runtime/issues/2937 - opts := server.DefaultManagerOptions(pool.Namespace, pool.Name) - opts.Controller = config.Controller{SkipNameValidation: ptr.To(true)} - mgr, err := ctrl.NewManager(cfg, opts) - if err != nil { - logutil.Fatal(logger, err, "Failed to create controller manager") - } - - if err := registerMetricsHandler(mgr, metricsPort); err != nil { - logutil.Fatal(logger, err, "Failed to register metrics handler") - } - - serverRunner = server.NewDefaultExtProcServerRunner() - serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} - pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) - // Adjust from defaults - serverRunner.PoolName = pool.Name - serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) - serverRunner.SecureServing = false - - ctx := ctrl.SetupSignalHandler() - if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { - logutil.Fatal(logger, err, "Failed to setup server runner") - } - - // Start the controller manager in a go routine, not blocking - go func() { - if err := mgr.Start(ctx); err != nil { - logutil.Fatal(logger, err, "Failed to start manager") - } - }() - - logger.Info("Setting up hermetic ExtProc server") - - if err := k8sClient.Create(context.Background(), pool); err != nil { - logutil.Fatal(logger, err, "Unable to create inferencePool", "pool", pool.Name) - } - - return func() { - _ = testEnv.Stop() - _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferencePool{}) - _ = k8sClient.DeleteAllOf(context.Background(), &v1alpha2.InferenceModel{}) - } -} - -func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - - res, err := client.Recv() - if err != nil { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - return res, err -} - -func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { - for _, req := range requests { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - // Brief pause for the goroutines to execute sequentially and populate the internal pipe channels sequentially - // without the pause there can be a race condition where a goroutine from a subsequent request is able to populate - // the pipe writer channel before a previous chunk. This is simply due to everything running in memory, this would - // not happen in a real world environment with non-zero latency. - time.Sleep(1 * time.Millisecond) - } - responses := []*extProcPb.ProcessingResponse{} - - // Make an incredible simple timeout func in the case where - // there is less than the expected amount of responses; bail and fail. - var simpleTimeout bool - go func() { - time.Sleep(10 * time.Second) - simpleTimeout = true - }() - - for range expectedResponses { - if simpleTimeout { - break - } - res, err := client.Recv() - if err != nil && err != io.EOF { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - responses = append(responses, res) - } - return responses, nil -} - -func makeMetadata(endpoint string) *structpb.Struct { - return &structpb.Struct{ - Fields: map[string]*structpb.Value{ - server.DefaultDestinationEndpointHintMetadataNamespace: { - Kind: &structpb.Value_StructValue{ - StructValue: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - server.DefaultDestinationEndpointHintKey: { - Kind: &structpb.Value_StringValue{ - StringValue: endpoint, - }, - }, - }, - }, - }, - }, - }, - } -} - -// registerMetricsHandler is a simplified version of metrics endpoint handler -// without Authentication for integration tests. -func registerMetricsHandler(mgr manager.Manager, port int) error { - metrics.Register() - - // Init HTTP server. - h := promhttp.HandlerFor( - legacyregistry.DefaultGatherer, - promhttp.HandlerOpts{}, - ) - - mux := http.NewServeMux() - mux.Handle("/metrics", h) - - srv := &http.Server{ - Addr: net.JoinHostPort("", strconv.Itoa(port)), - Handler: mux, - } - - if err := mgr.Add(&manager.Server{ - Name: "metrics", - Server: srv, - }); err != nil { - return err - } - return nil -} diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml new file mode 100644 index 00000000..36b6e539 --- /dev/null +++ b/test/testdata/inferencepool-with-model-hermetic.yaml @@ -0,0 +1,63 @@ +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + name: vllm-llama2-7b-pool + namespace: default +spec: + targetPortNumber: 8000 + selector: + app: vllm-llama2-7b-pool + extensionRef: + name: epp +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-sample + namespace: default +spec: + modelName: sql-lora + criticality: Critical + poolRef: + name: vllm-llama2-7b-pool + targetModels: + - name: sql-lora-1fdg2 + weight: 100 +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-sheddable + namespace: default +spec: + modelName: sql-lora-sheddable + poolRef: + name: vllm-llama2-7b-pool + targetModels: + - name: sql-lora-1fdg3 + weight: 100 +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-generic + namespace: default +spec: + modelName: my-model + criticality: Critical + poolRef: + name: vllm-llama2-7b-pool + targetModels: + - name: my-model-12345 + weight: 100 +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: inferencemodel-direct-model-name + namespace: default +spec: + modelName: direct-model + criticality: Critical + poolRef: + name: vllm-llama2-7b-pool \ No newline at end of file From 62964e312d96749e897fc685052773d05e4014ca Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Wed, 19 Mar 2025 23:40:30 +0000 Subject: [PATCH 131/260] Add inferencepool chart push mechanics (#540) --- Makefile | 20 ++++++++++- cloudbuild.yaml | 7 ++++ config/charts/inferencepool/Chart.yaml | 2 +- hack/push-chart.sh | 47 ++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100755 hack/push-chart.sh diff --git a/Makefile b/Makefile index e5b50319..4933caa2 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,8 @@ IMAGE_BUILD_CMD ?= $(DOCKER_BUILDX_CMD) build IMAGE_BUILD_EXTRA_OPTS ?= SYNCER_IMAGE_BUILD_EXTRA_OPTS ?= BBR_IMAGE_BUILD_EXTRA_OPTS ?= -IMAGE_REGISTRY ?= us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension +STAGING_IMAGE_REGISTRY ?= us-central1-docker.pkg.dev/k8s-staging-images +IMAGE_REGISTRY ?= $(STAGING_IMAGE_REGISTRY)/gateway-api-inference-extension IMAGE_NAME := epp IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(IMAGE_NAME) IMAGE_TAG ?= $(IMAGE_REPO):$(GIT_TAG) @@ -291,6 +292,12 @@ install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - + +##@ Helm +PHONY: inferencepool-helm-chart-push +inferencepool-helm-chart-push: yq helm + CHART=inferencepool EXTRA_TAG="$(EXTRA_TAG)" IMAGE_REGISTRY="$(IMAGE_REGISTRY)" YQ="$(YQ)" HELM="$(HELM)" ./hack/push-chart.sh + ##@ Release .PHONY: release-quickstart @@ -320,12 +327,15 @@ KUSTOMIZE ?= $(LOCALBIN)/kustomize CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen ENVTEST ?= $(LOCALBIN)/setup-envtest GOLANGCI_LINT = $(LOCALBIN)/golangci-lint +HELM = $(PROJECT_DIR)/bin/helm +YQ = $(PROJECT_DIR)/bin/yq ## Tool Versions KUSTOMIZE_VERSION ?= v5.4.3 CONTROLLER_TOOLS_VERSION ?= v0.16.1 ENVTEST_VERSION ?= release-0.19 GOLANGCI_LINT_VERSION ?= v1.62.2 +HELM_VERSION ?= v3.17.1 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. @@ -347,6 +357,14 @@ golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) +.PHONY: yq +yq: ## Download yq locally if necessary. + GOBIN=$(PROJECT_DIR)/bin GO111MODULE=on go install github.com/mikefarah/yq/v4@v4.45.1 + +.PHONY: helm +helm: ## Download helm locally if necessary. + GOBIN=$(PROJECT_DIR)/bin GO111MODULE=on go install helm.sh/helm/v3/cmd/helm@$(HELM_VERSION) + # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist # $1 - target path with name of binary # $2 - package url which can be installed diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 3a8e008f..ef0499d9 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -20,6 +20,13 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint + - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc + entrypoint: make + args: + - inferencepool-helm-chart-push + env: + - GIT_TAG=$_GIT_TAG + - EXTRA_TAG=$_PULL_BASE_REF - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc entrypoint: make args: diff --git a/config/charts/inferencepool/Chart.yaml b/config/charts/inferencepool/Chart.yaml index 5e46737c..0ce46e79 100644 --- a/config/charts/inferencepool/Chart.yaml +++ b/config/charts/inferencepool/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: InferencePool +name: inferencepool description: A Helm chart for InferencePool type: application diff --git a/hack/push-chart.sh b/hack/push-chart.sh new file mode 100755 index 00000000..a7a497a2 --- /dev/null +++ b/hack/push-chart.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +# Copyright 2025 The Kubernetes 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. + +set -o errexit +set -o nounset +set -o pipefail + +DEST_CHART_DIR=${DEST_CHART_DIR:-bin/} + +EXTRA_TAG=${EXTRA_TAG:-$(git branch --show-current)} +GIT_TAG=${GIT_TAG:-$(git tag | sort | grep -v rc | tail -n1)-$(git describe --tags --dirty --always)} + +STAGING_IMAGE_REGISTRY=${STAGING_IMAGE_REGISTRY:-us-central1-docker.pkg.dev/k8s-staging-images} +IMAGE_REGISTRY=${IMAGE_REGISTRY:-${STAGING_IMAGE_REGISTRY}/gateway-api-inference-extension} +HELM_CHART_REPO=${HELM_CHART_REPO:-${STAGING_IMAGE_REGISTRY}/gateway-api-inference-extension/charts} +CHART=${CHART:-inferencepool} + +HELM=${HELM:-./bin/helm} + +readonly semver_regex='^v([0-9]+)(\.[0-9]+){1,2}$' + +chart_version=${GIT_TAG} +if [[ ${EXTRA_TAG} =~ ${semver_regex} ]] +then + ${YQ} -i '.inferenceExtension.image.tag=strenv(EXTRA_TAG)' config/charts/inferencepool/values.yaml + chart_version=${EXTRA_TAG} +fi + +# Create the package +${HELM} package --version "${chart_version}" --app-version "${chart_version}" "config/charts/${CHART}" -d "${DEST_CHART_DIR}" + +# Push the package +echo "pushing chart to ${HELM_CHART_REPO}" +${HELM} push "bin/${CHART}-${chart_version}.tgz" "oci://${HELM_CHART_REPO}" From 231436d808ab3af9d2266ab8ae678bffc4e4d3ae Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 20 Mar 2025 00:50:30 +0000 Subject: [PATCH 132/260] Updated the image used for cloudbuild (#542) --- cloudbuild.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index ef0499d9..0f9d7756 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -4,7 +4,7 @@ timeout: 3000s # For each build step, Prow executes a job. steps: # see https://github.com/kubernetes/test-infra/tree/master/config/jobs/image-pushing - - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc + - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: - image-push @@ -12,7 +12,7 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint - - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc + - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: - syncer-image-push @@ -20,14 +20,14 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint - - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc + - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: - inferencepool-helm-chart-push env: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - - name: gcr.io/k8s-testimages/gcb-docker-gcloud:v20220830-45cbff55bc + - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: - bbr-image-push From bab4331dc0bce25371f0f39fae87322f43811bab Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 20 Mar 2025 03:02:30 +0000 Subject: [PATCH 133/260] setting gotoolchain to auto (#543) --- cloudbuild.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 0f9d7756..201d74ce 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -27,6 +27,7 @@ steps: env: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF + - GOTOOLCHAIN=auto - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: From fcbdc143298e7d70969fc373cb53554934db7730 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Wed, 19 Mar 2025 23:48:30 -0400 Subject: [PATCH 134/260] simplify body streaming (#544) --- pkg/body-based-routing/handlers/server.go | 27 +++++------------------ 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/body-based-routing/handlers/server.go index 36eb3c2f..fee8f78c 100644 --- a/pkg/body-based-routing/handlers/server.go +++ b/pkg/body-based-routing/handlers/server.go @@ -46,7 +46,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { loggerVerbose := logger.V(logutil.VERBOSE) loggerVerbose.Info("Processing") - reader, writer := io.Pipe() + var streamedBody []byte for { select { @@ -78,7 +78,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } case *extProcPb.ProcessingRequest_RequestBody: loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) - responses, err = s.processRequestBody(ctx, req.GetRequestBody(), writer, reader, logger) + responses, err = s.processRequestBody(ctx, req.GetRequestBody(), streamedBody, logger) case *extProcPb.ProcessingRequest_RequestTrailers: responses, err = s.HandleRequestTrailers(req.GetRequestTrailers()) case *extProcPb.ProcessingRequest_ResponseHeaders: @@ -105,35 +105,20 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } -func (s *Server) processRequestBody(ctx context.Context, body *extProcPb.HttpBody, bufferWriter *io.PipeWriter, bufferReader *io.PipeReader, logger logr.Logger) ([]*extProcPb.ProcessingResponse, error) { +func (s *Server) processRequestBody(ctx context.Context, body *extProcPb.HttpBody, streamedBody []byte, logger logr.Logger) ([]*extProcPb.ProcessingResponse, error) { loggerVerbose := logger.V(logutil.VERBOSE) var requestBody map[string]interface{} if s.streaming { // In the stream case, we can receive multiple request bodies. - // To buffer the full message, we create a goroutine with a writer.Write() - // call, which will block until the corresponding reader reads from it. - // We do not read until we receive the EndofStream signal, and then - // decode the entire JSON body. - if !body.EndOfStream { - go func() { - loggerVerbose.Info("Writing to stream buffer") - _, err := bufferWriter.Write(body.Body) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error populating writer") - } - }() - - return nil, nil - } + streamedBody = append(streamedBody, body.Body...) if body.EndOfStream { loggerVerbose.Info("Flushing stream buffer") - decoder := json.NewDecoder(bufferReader) - if err := decoder.Decode(&requestBody); err != nil { + err := json.Unmarshal(streamedBody, &requestBody) + if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") } - bufferReader.Close() } } else { if err := json.Unmarshal(body.GetBody(), &requestBody); err != nil { From 03d8584d196736b05af135c4f812deb7828fe32d Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Wed, 19 Mar 2025 21:28:31 -0700 Subject: [PATCH 135/260] Bug fix: Initialize RequestReceivedTimestamp (#539) --- pkg/epp/handlers/streamingserver.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 2b471232..28f28e87 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -90,6 +90,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) switch v := req.Request.(type) { case *extProcPb.ProcessingRequest_RequestHeaders: + reqCtx.RequestReceivedTimestamp = time.Now() // Do nothing. Header info is handled in the HandleRequestBody func case *extProcPb.ProcessingRequest_RequestBody: loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) From 9bcbfe4df1f00232a85ca2b8d1f5d854cec30d44 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Thu, 20 Mar 2025 12:00:31 -0400 Subject: [PATCH 136/260] [Metrics] Handle vLLM streaming response in streaming server (#518) - Update streaming integration test when the response includes usage, the DONE message is returned together with the last message. The end of stream contains empty message. --- pkg/epp/handlers/response.go | 78 ++++++++++++++++----------- pkg/epp/handlers/streamingserver.go | 28 ++++++++++ test/integration/epp/hermetic_test.go | 74 +++++++++++++++++-------- 3 files changed, 127 insertions(+), 53 deletions(-) diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index 44ea6d6a..1452fdd2 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -30,6 +30,11 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +const ( + streamingRespPrefix = "data: " + streamingEndMsg = "data: [DONE]" +) + // HandleResponseHeaders processes response headers from the backend model server. func (s *Server) HandleResponseHeaders( ctx context.Context, @@ -197,39 +202,10 @@ func (s *Server) HandleStreaming( body *extProcPb.ProcessingRequest_ResponseBody, loggerVerbose logr.Logger, ) error { - respPrefix := "data: " responseText := string(body.ResponseBody.Body) - // Example message if "stream_options": {"include_usage": "true"} is included in the request: - // data: {"id":"...","object":"text_completion","created":1739400043,"model":"tweet-summary-0","choices":[], - // "usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} - // - // data: [DONE] - // - // Noticed that vLLM returns two entries in one response. - // We need to strip the `data:` prefix and next Data: [DONE] from the message to fetch response data. - // - // If include_usage is not included in the request, `data: [DONE]` is returned separately, which - // indicates end of streaming. - if strings.Contains(responseText, "data: [DONE]") { - response := Response{} - - lines := strings.Split(responseText, "\n") - for _, line := range lines { - if !strings.HasPrefix(line, respPrefix) { - continue - } - content := strings.TrimPrefix(line, respPrefix) - if content == "[DONE]" { - continue - } - - byteSlice := []byte(content) - if err := json.Unmarshal(byteSlice, &response); err != nil { - loggerVerbose.Error(err, "unmarshaling response body") - continue - } - } - reqCtx.Response = response + if strings.Contains(responseText, streamingEndMsg) { + parsedResp := ParseRespForUsage(ctx, responseText, loggerVerbose) + reqCtx.Response = parsedResp } if body.ResponseBody.EndOfStream { @@ -242,6 +218,44 @@ func (s *Server) HandleStreaming( return nil } +// Example message if "stream_options": {"include_usage": "true"} is included in the request: +// data: {"id":"...","object":"text_completion","created":1739400043,"model":"tweet-summary-0","choices":[], +// "usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} +// +// data: [DONE] +// +// Noticed that vLLM returns two entries in one response. +// We need to strip the `data:` prefix and next Data: [DONE] from the message to fetch response data. +// +// If include_usage is not included in the request, `data: [DONE]` is returned separately, which +// indicates end of streaming. +func ParseRespForUsage( + ctx context.Context, + responseText string, + loggerVerbose logr.Logger, +) Response { + response := Response{} + + lines := strings.Split(responseText, "\n") + for _, line := range lines { + if !strings.HasPrefix(line, streamingRespPrefix) { + continue + } + content := strings.TrimPrefix(line, streamingRespPrefix) + if content == "[DONE]" { + continue + } + + byteSlice := []byte(content) + if err := json.Unmarshal(byteSlice, &response); err != nil { + loggerVerbose.Error(err, "unmarshaling response body") + continue + } + } + + return response +} + type Response struct { Usage Usage `json:"usage"` } diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 28f28e87..684a7542 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -157,6 +157,17 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) case *extProcPb.ProcessingRequest_ResponseBody: if reqCtx.modelServerStreaming { // Currently we punt on response parsing if the modelServer is streaming, and we just passthrough. + + responseText := string(v.ResponseBody.Body) + s.HandleResponseBodyModelStreaming(ctx, reqCtx, responseText) + if v.ResponseBody.EndOfStream { + loggerVerbose.Info("streaming is completed") + + reqCtx.ResponseCompleteTimestamp = time.Now() + metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) + metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) + } + reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ Response: &extProcPb.ProcessingResponse_ResponseBody{ ResponseBody: &extProcPb.BodyResponse{ @@ -526,3 +537,20 @@ func (s *StreamingServer) HandleResponseBody( } return reqCtx, nil } + +// The function is to handle streaming response if the modelServer is streaming. +func (s *StreamingServer) HandleResponseBodyModelStreaming( + ctx context.Context, + reqCtx *StreamingRequestContext, + responseText string, +) { + logger := log.FromContext(ctx) + loggerVerbose := logger.V(logutil.VERBOSE) + loggerVerbose.Info("Processing HandleResponseBody") + + if strings.Contains(responseText, streamingEndMsg) { + resp := ParseRespForUsage(ctx, responseText, loggerVerbose) + metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.PromptTokens) + metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.CompletionTokens) + } +} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index bb73eafc..cb18eaa4 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -403,7 +403,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { requests []*extProcPb.ProcessingRequest pods map[backendmetrics.Pod]*backendmetrics.Metrics wantResponses []*extProcPb.ProcessingResponse - wantMetrics string + wantMetrics map[string]string wantErr bool immediateResponse *extProcPb.ImmediateResponse }{ @@ -426,11 +426,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { KVCacheUsagePercent: 0.2, }, }, - wantMetrics: ` + wantMetrics: map[string]string{`inference_model_request_total`: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 - `, + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -507,11 +507,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetrics: ` + wantMetrics: map[string]string{`inference_model_request_total`: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `, + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -588,11 +588,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetrics: ` + wantMetrics: map[string]string{`inference_model_request_total`: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `, + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -671,7 +671,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantErr: false, - wantMetrics: "", + wantMetrics: map[string]string{}, wantResponses: []*extProcPb.ProcessingResponse{ { Response: &extProcPb.ProcessingResponse_ImmediateResponse{ @@ -715,11 +715,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetrics: ` + wantMetrics: map[string]string{`inference_model_request_total`: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 - `, + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -823,11 +823,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetrics: ` + wantMetrics: map[string]string{`inference_model_request_total`: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 - `, + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -931,11 +931,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, - wantMetrics: ` + wantMetrics: map[string]string{`inference_model_request_total`: ` # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="direct-model",target_model_name="direct-model"} 1 - `, + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -1233,19 +1233,47 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} +data: [DONE]`, + ), EndOfStream: false}, }, }, { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte("data: [DONE]"), + Body: []byte(""), EndOfStream: true}, }, }, }, wantErr: false, + wantMetrics: map[string]string{`inference_model_input_tokens`: ` + # HELP inference_model_input_tokens [ALPHA] Inference model input token count distribution for requests in each model. + # TYPE inference_model_input_tokens histogram + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1"} 0 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="8"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="16"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="32"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="64"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="128"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="256"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="512"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1024"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="2048"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="4096"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="8192"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="16384"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="32778"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="65536"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="131072"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="262144"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="524288"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1.048576e+06"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="+Inf"} 1 + inference_model_input_tokens_sum{model_name="",target_model_name=""} 7 + inference_model_input_tokens_count{model_name="",target_model_name=""} 1 + `}, wantResponses: []*extProcPb.ProcessingResponse{ { Response: &extProcPb.ProcessingResponse_ResponseHeaders{ @@ -1352,7 +1380,9 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} +data: [DONE]`, + ), EndOfStream: false, }, }, @@ -1368,7 +1398,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte("data: [DONE]"), + Body: []byte(""), EndOfStream: true, }, }, @@ -1394,9 +1424,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { t.Errorf("Unexpected response, (-want +got): %v", diff) } - if test.wantMetrics != "" { - if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(test.wantMetrics), "inference_model_request_total"); err != nil { - t.Error(err) + if len(test.wantMetrics) != 0 { + for metricName, value := range test.wantMetrics { + if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(value), metricName); err != nil { + t.Error(err) + } } } From 4aa1019721b8fe24169df0fe3bae83b6f03bc06e Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 20 Mar 2025 13:24:33 -0400 Subject: [PATCH 137/260] Add some more unit tests for BBR (#545) --- pkg/body-based-routing/handlers/server.go | 17 +- .../handlers/server_test.go | 145 ++++++++++++++++++ 2 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 pkg/body-based-routing/handlers/server_test.go diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/body-based-routing/handlers/server.go index fee8f78c..24664f98 100644 --- a/pkg/body-based-routing/handlers/server.go +++ b/pkg/body-based-routing/handlers/server.go @@ -46,7 +46,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { loggerVerbose := logger.V(logutil.VERBOSE) loggerVerbose.Info("Processing") - var streamedBody []byte + streamedBody := &streamedBody{} for { select { @@ -105,17 +105,22 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } -func (s *Server) processRequestBody(ctx context.Context, body *extProcPb.HttpBody, streamedBody []byte, logger logr.Logger) ([]*extProcPb.ProcessingResponse, error) { +type streamedBody struct { + body []byte +} + +func (s *Server) processRequestBody(ctx context.Context, body *extProcPb.HttpBody, streamedBody *streamedBody, logger logr.Logger) ([]*extProcPb.ProcessingResponse, error) { loggerVerbose := logger.V(logutil.VERBOSE) var requestBody map[string]interface{} if s.streaming { // In the stream case, we can receive multiple request bodies. - streamedBody = append(streamedBody, body.Body...) - - if body.EndOfStream { + if !body.EndOfStream { + streamedBody.body = append(streamedBody.body, body.Body...) + return nil, nil + } else { loggerVerbose.Info("Flushing stream buffer") - err := json.Unmarshal(streamedBody, &requestBody) + err := json.Unmarshal(streamedBody.body, &requestBody) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") } diff --git a/pkg/body-based-routing/handlers/server_test.go b/pkg/body-based-routing/handlers/server_test.go new file mode 100644 index 00000000..f4e8e254 --- /dev/null +++ b/pkg/body-based-routing/handlers/server_test.go @@ -0,0 +1,145 @@ +/* +Copyright 2025 The Kubernetes 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 handlers + +import ( + "context" + "testing" + + basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/testing/protocmp" + "sigs.k8s.io/controller-runtime/pkg/log" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func TestProcessRequestBody(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + + cases := []struct { + desc string + streaming bool + bodys []*extProcPb.HttpBody + want []*extProcPb.ProcessingResponse + }{ + { + desc: "no-streaming", + bodys: []*extProcPb.HttpBody{ + { + Body: mapToBytes(t, map[string]any{ + "model": "foo", + }), + }, + }, + want: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + // Necessary so that the new headers are used in the routing decision. + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: modelHeader, + RawValue: []byte("foo"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + desc: "streaming", + streaming: true, + bodys: []*extProcPb.HttpBody{ + { + Body: mapToBytes(t, map[string]any{ + "model": "foo", + }), + }, + { + EndOfStream: true, + }, + }, + want: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*basepb.HeaderValueOption{ + { + Header: &basepb.HeaderValue{ + Key: modelHeader, + RawValue: []byte("foo"), + }, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: mapToBytes(t, map[string]any{ + "model": "foo", + }), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + srv := NewServer(tc.streaming) + streamedBody := &streamedBody{} + for i, body := range tc.bodys { + got, err := srv.processRequestBody(context.Background(), body, streamedBody, log.FromContext(ctx)) + if err != nil { + t.Fatalf("processRequestBody(): %v", err) + } + + if i == len(tc.bodys)-1 { + if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" { + t.Errorf("processRequestBody returned unexpected response, diff(-want, +got): %v", diff) + } + } + } + }) + } +} From fca9d2a04716c1ff8a6efe6575f716d47e1d19d1 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:28:31 +0000 Subject: [PATCH 138/260] Tag the main version of the helm chart with v0 (i.e., latest dev version) (#547) --- cloudbuild.yaml | 1 - hack/push-chart.sh | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 201d74ce..82c594c6 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -25,7 +25,6 @@ steps: args: - inferencepool-helm-chart-push env: - - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - GOTOOLCHAIN=auto - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 diff --git a/hack/push-chart.sh b/hack/push-chart.sh index a7a497a2..e1cbbb1f 100755 --- a/hack/push-chart.sh +++ b/hack/push-chart.sh @@ -21,7 +21,7 @@ set -o pipefail DEST_CHART_DIR=${DEST_CHART_DIR:-bin/} EXTRA_TAG=${EXTRA_TAG:-$(git branch --show-current)} -GIT_TAG=${GIT_TAG:-$(git tag | sort | grep -v rc | tail -n1)-$(git describe --tags --dirty --always)} +CHART_VERSION=${CHART_VERSION:-"v0"} STAGING_IMAGE_REGISTRY=${STAGING_IMAGE_REGISTRY:-us-central1-docker.pkg.dev/k8s-staging-images} IMAGE_REGISTRY=${IMAGE_REGISTRY:-${STAGING_IMAGE_REGISTRY}/gateway-api-inference-extension} @@ -32,9 +32,10 @@ HELM=${HELM:-./bin/helm} readonly semver_regex='^v([0-9]+)(\.[0-9]+){1,2}$' -chart_version=${GIT_TAG} +chart_version=${CHART_VERSION} if [[ ${EXTRA_TAG} =~ ${semver_regex} ]] then + # This is a release branch, use the release version ${YQ} -i '.inferenceExtension.image.tag=strenv(EXTRA_TAG)' config/charts/inferencepool/values.yaml chart_version=${EXTRA_TAG} fi From 76cdc7d083f335be8201f98dfa264f906b88a40d Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 20 Mar 2025 19:10:31 +0000 Subject: [PATCH 139/260] Default to streaming mode (#552) --- config/charts/inferencepool/README.md | 16 ++++- .../templates/inferencepool.yaml | 3 + config/manifests/gateway/patch_policy.yaml | 62 +++++++++---------- config/manifests/inferencepool.yaml | 2 +- 4 files changed, 48 insertions(+), 35 deletions(-) diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md index ee0481d3..da9d0a07 100644 --- a/config/charts/inferencepool/README.md +++ b/config/charts/inferencepool/README.md @@ -5,17 +5,27 @@ A chart to deploy an InferencePool and a corresponding EndpointPicker (epp) depl ## Install -To install an InferencePool named `pool-1` that selects from endpoints with label `app: vllm-llama2-7b` and listening on port `8000`, you can run the following command: +To install an InferencePool named `vllm-llama2-7b` that selects from endpoints with label `app: vllm-llama2-7b` and listening on port `8000`, you can run the following command: ```txt -$ helm install pool-1 ./config/charts/inferencepool \ - --set inferencePool.name=pool-1 \ +$ helm install vllm-llama2-7b ./config/charts/inferencepool \ + --set inferencePool.name=vllm-llama2-7b \ --set inferencePool.selector.app=vllm-llama2-7b \ --set inferencePool.targetPortNumber=8000 ``` where `inferencePool.targetPortNumber` is the pod that vllm backends served on and `inferencePool.selector` is the selector to match the vllm backends. +To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: + +```txt +$ helm install vllm-llama2-7b \ + --set inferencePool.name=vllm-llama2-7b \ + --set inferencePool.selector.app=vllm-llama2-7b \ + --set inferencePool.targetPortNumber=8000 \ + oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0 +``` + ## Uninstall Run the following command to uninstall the chart: diff --git a/config/charts/inferencepool/templates/inferencepool.yaml b/config/charts/inferencepool/templates/inferencepool.yaml index 8fc97496..fb750f63 100644 --- a/config/charts/inferencepool/templates/inferencepool.yaml +++ b/config/charts/inferencepool/templates/inferencepool.yaml @@ -49,6 +49,9 @@ spec: - "9003" - -metricsPort - "9090" + env: + - name: USE_STREAMING + value: "true" ports: - name: grpc containerPort: 9002 diff --git a/config/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml index d293bc82..76417d16 100644 --- a/config/manifests/gateway/patch_policy.yaml +++ b/config/manifests/gateway/patch_policy.yaml @@ -54,37 +54,37 @@ spec: op: replace path: "/virtual_hosts/0/routes/0/route/cluster" value: original_destination_cluster -# Uncomment the below to enable full duplex streaming - # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - # name: "default/inference-gateway/llm-gw" - # operation: - # op: add - # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_body_mode" - # value: FULL_DUPLEX_STREAMED - # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - # name: "default/inference-gateway/llm-gw" - # operation: - # op: add - # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_trailer_mode" - # value: SEND - # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - # name: "default/inference-gateway/llm-gw" - # operation: - # op: add - # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_body_mode" - # value: FULL_DUPLEX_STREAMED - # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - # name: "default/inference-gateway/llm-gw" - # operation: - # op: replace - # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_trailer_mode" - # value: SEND - # - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - # name: "default/inference-gateway/llm-gw" - # operation: - # op: replace - # path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_header_mode" - # value: SEND +# Comment the below to disable full duplex streaming + - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + name: "default/inference-gateway/llm-gw" + operation: + op: add + path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_body_mode" + value: FULL_DUPLEX_STREAMED + - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + name: "default/inference-gateway/llm-gw" + operation: + op: add + path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_trailer_mode" + value: SEND + - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + name: "default/inference-gateway/llm-gw" + operation: + op: add + path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_body_mode" + value: FULL_DUPLEX_STREAMED + - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + name: "default/inference-gateway/llm-gw" + operation: + op: replace + path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_trailer_mode" + value: SEND + - type: "type.googleapis.com/envoy.config.listener.v3.Listener" + name: "default/inference-gateway/llm-gw" + operation: + op: replace + path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_header_mode" + value: SEND --- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: EnvoyExtensionPolicy diff --git a/config/manifests/inferencepool.yaml b/config/manifests/inferencepool.yaml index 64008639..ca2e4a88 100644 --- a/config/manifests/inferencepool.yaml +++ b/config/manifests/inferencepool.yaml @@ -56,7 +56,7 @@ spec: - "9003" env: - name: USE_STREAMING - value: "false" + value: "true" ports: - containerPort: 9002 - containerPort: 9003 From 189f0dcf9aa0e8f56c2f74b3d7f3c63cf0f083cc Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 20 Mar 2025 16:54:32 -0400 Subject: [PATCH 140/260] Helm chart for bbr (#546) --- config/charts/body-based-routing/.helmignore | 23 ++++++++++ config/charts/body-based-routing/Chart.yaml | 9 ++++ config/charts/body-based-routing/README.md | 42 +++++++++++++++++++ .../body-based-routing/templates/NOTES.txt | 1 + .../body-based-routing/templates/bbr.yaml | 42 +++++++++++++++++++ config/charts/body-based-routing/values.yaml | 9 ++++ 6 files changed, 126 insertions(+) create mode 100644 config/charts/body-based-routing/.helmignore create mode 100644 config/charts/body-based-routing/Chart.yaml create mode 100644 config/charts/body-based-routing/README.md create mode 100644 config/charts/body-based-routing/templates/NOTES.txt create mode 100644 config/charts/body-based-routing/templates/bbr.yaml create mode 100644 config/charts/body-based-routing/values.yaml diff --git a/config/charts/body-based-routing/.helmignore b/config/charts/body-based-routing/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/config/charts/body-based-routing/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/config/charts/body-based-routing/Chart.yaml b/config/charts/body-based-routing/Chart.yaml new file mode 100644 index 00000000..952a84f0 --- /dev/null +++ b/config/charts/body-based-routing/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: body-based-routing +description: A Helm chart for the body-based routing extension + +type: application + +version: 0.1.0 + +appVersion: "0.2.0" diff --git a/config/charts/body-based-routing/README.md b/config/charts/body-based-routing/README.md new file mode 100644 index 00000000..2a8d96a8 --- /dev/null +++ b/config/charts/body-based-routing/README.md @@ -0,0 +1,42 @@ +# Body-based routing + +A chart to the body-based routing deployment and service. + + +## Install + +To install a body-based router named `body-based-router`, you can run the following command: + +```txt +$ helm install body-based-router ./config/charts/body-based-routing +``` + +## Uninstall + +Run the following command to uninstall the chart: + +```txt +$ helm uninstall body-based-router +``` + +## Configuration + +The following table list the configurable parameters of the chart. + +| **Parameter Name** | **Description** | +|---------------------------------------------|----------------------------------------------------------------------------------------------------| +| `bbr.name` | Name for the deployment and service. | +| `bbr.replicas` | Number of replicas for the deployment. Defaults to `1`. | +| `bbr.image.name` | Name of the container image used. | +| `bbr.image.hub` | Registry URL where the image is hosted. | +| `bbr.image.tag` | Image tag. | +| `bbr.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | + +## Notes + +This chart will only deploy the body-based router deployment and service. +Note that this should only be deployed once per Gateway. + +Additional configuration is needed to configure a proxy extension that calls +out to the service in the request path. For example, vwith Envoy Gateway, this +would require configuring EnvoyExtensionPolicy. diff --git a/config/charts/body-based-routing/templates/NOTES.txt b/config/charts/body-based-routing/templates/NOTES.txt new file mode 100644 index 00000000..0a382009 --- /dev/null +++ b/config/charts/body-based-routing/templates/NOTES.txt @@ -0,0 +1 @@ +Body-based routing extension deployed. diff --git a/config/charts/body-based-routing/templates/bbr.yaml b/config/charts/body-based-routing/templates/bbr.yaml new file mode 100644 index 00000000..4b888dcb --- /dev/null +++ b/config/charts/body-based-routing/templates/bbr.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.bbr.name }} + namespace: {{ .Release.Namespace }} +spec: + replicas: {{ .Values.bbr.replicas | default 1 }} + selector: + matchLabels: + app: {{ .Values.bbr.name }} + template: + metadata: + labels: + app: {{ .Values.bbr.name }} + spec: + containers: + - name: bbr + image: {{ .Values.bbr.image.hub }}/{{ .Values.bbr.image.name }}:{{ .Values.bbr.image.tag }} + imagePullPolicy: {{ .Values.bbr.image.pullPolicy | default "Always" }} + args: + - "-streaming" + - "v" + - "3" + ports: + - containerPort: 9004 + # health check + - containerPort: 9005 +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.bbr.name }} + namespace: {{ .Release.Namespace }} +spec: + selector: + app: {{ .Values.bbr.name }} + ports: + - protocol: TCP + port: 9004 + targetPort: 9004 + appProtocol: HTTP2 + type: ClusterIP diff --git a/config/charts/body-based-routing/values.yaml b/config/charts/body-based-routing/values.yaml new file mode 100644 index 00000000..b60f5d69 --- /dev/null +++ b/config/charts/body-based-routing/values.yaml @@ -0,0 +1,9 @@ +bbr: + name: body-based-router + replicas: 1 + image: + name: bbr + hub: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension + tag: main + pullPolicy: Always + extProcPort: 9002 From afab4b782a1669e9c5006d5c6b4616b6c197aa29 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Fri, 21 Mar 2025 12:16:33 -0400 Subject: [PATCH 141/260] add makefile configs for bbr helm chart (#553) --- Makefile | 4 ++++ cloudbuild.yaml | 1 + config/charts/body-based-routing/README.md | 6 ++++++ hack/push-chart.sh | 3 +-- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 4933caa2..400ec07e 100644 --- a/Makefile +++ b/Makefile @@ -298,6 +298,10 @@ PHONY: inferencepool-helm-chart-push inferencepool-helm-chart-push: yq helm CHART=inferencepool EXTRA_TAG="$(EXTRA_TAG)" IMAGE_REGISTRY="$(IMAGE_REGISTRY)" YQ="$(YQ)" HELM="$(HELM)" ./hack/push-chart.sh +PHONY: bbr-helm-chart-push +bbr-helm-chart-push: yq helm + CHART=body-based-routing EXTRA_TAG="$(EXTRA_TAG)" IMAGE_REGISTRY="$(IMAGE_REGISTRY)" YQ="$(YQ)" HELM="$(HELM)" ./hack/push-chart.sh + ##@ Release .PHONY: release-quickstart diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 82c594c6..6043d225 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -24,6 +24,7 @@ steps: entrypoint: make args: - inferencepool-helm-chart-push + - bbr-helm-chart-push env: - EXTRA_TAG=$_PULL_BASE_REF - GOTOOLCHAIN=auto diff --git a/config/charts/body-based-routing/README.md b/config/charts/body-based-routing/README.md index 2a8d96a8..4ef0c201 100644 --- a/config/charts/body-based-routing/README.md +++ b/config/charts/body-based-routing/README.md @@ -11,6 +11,12 @@ To install a body-based router named `body-based-router`, you can run the follow $ helm install body-based-router ./config/charts/body-based-routing ``` +To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: + +```txt +$ helm install body-based-router oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/body-based-router --version v0 +``` + ## Uninstall Run the following command to uninstall the chart: diff --git a/hack/push-chart.sh b/hack/push-chart.sh index e1cbbb1f..e0938af4 100755 --- a/hack/push-chart.sh +++ b/hack/push-chart.sh @@ -35,8 +35,7 @@ readonly semver_regex='^v([0-9]+)(\.[0-9]+){1,2}$' chart_version=${CHART_VERSION} if [[ ${EXTRA_TAG} =~ ${semver_regex} ]] then - # This is a release branch, use the release version - ${YQ} -i '.inferenceExtension.image.tag=strenv(EXTRA_TAG)' config/charts/inferencepool/values.yaml + ${YQ} -i '.inferenceExtension.image.tag=strenv(EXTRA_TAG)' config/charts/${CHART}/values.yaml chart_version=${EXTRA_TAG} fi From 140d5eb2781060f25dca8d83b9b42c1e718bcbd7 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Fri, 21 Mar 2025 15:18:31 -0700 Subject: [PATCH 142/260] Adding deprecation notice of BUFFERED mode on patch policy. (#560) --- config/manifests/gateway/patch_policy.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml index 76417d16..a40c8e27 100644 --- a/config/manifests/gateway/patch_policy.yaml +++ b/config/manifests/gateway/patch_policy.yaml @@ -55,6 +55,9 @@ spec: path: "/virtual_hosts/0/routes/0/route/cluster" value: original_destination_cluster # Comment the below to disable full duplex streaming +# NOTE: As of https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/552 +# FULL_DUPLEX_STREAMED is the primary supported protocol for ext-proc. The buffered variant is no longer +# being actively developed, may be missing features/fixes, and will soon be removed. - type: "type.googleapis.com/envoy.config.listener.v3.Listener" name: "default/inference-gateway/llm-gw" operation: From 12bcc9a85dad828b146758ad34a69053dca44fa9 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Fri, 21 Mar 2025 16:36:31 -0700 Subject: [PATCH 143/260] Allow bodyless requests to passthrough EPP (#555) * Adding content length checker * Allow requests with no body to passthrough EPP --------- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pkg/epp/datastore/datastore.go | 31 --- pkg/epp/datastore/datastore_test.go | 108 ---------- pkg/epp/handlers/request.go | 2 +- pkg/epp/handlers/response.go | 10 +- pkg/epp/handlers/response_test.go | 28 ++- pkg/epp/handlers/server.go | 35 +++- pkg/epp/handlers/streamingserver.go | 256 +++++++++++++---------- pkg/epp/handlers/streamingserver_test.go | 131 ++++++++++++ test/integration/epp/hermetic_test.go | 159 +++++++++----- 9 files changed, 436 insertions(+), 324 deletions(-) create mode 100644 pkg/epp/handlers/streamingserver_test.go diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index af31da42..8ada3e64 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -20,10 +20,8 @@ import ( "context" "errors" "fmt" - "math/rand" "sync" - "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" @@ -304,35 +302,6 @@ func stripLabelKeyAliasFromLabelMap(labels map[v1alpha2.LabelKey]v1alpha2.LabelV return outMap } -func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed int64) string { - source := rand.NewSource(rand.Int63()) - if seed > 0 { - source = rand.NewSource(seed) - } - r := rand.New(source) - - // all the weight values are nil, then we should return random model name - if model.Spec.TargetModels[0].Weight == nil { - index := r.Int31n(int32(len(model.Spec.TargetModels))) - return model.Spec.TargetModels[index].Name - } - - var weights int32 - for _, model := range model.Spec.TargetModels { - weights += *model.Weight - } - logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) - randomVal := r.Int31n(weights) - // TODO: optimize this without using loop - for _, model := range model.Spec.TargetModels { - if randomVal < *model.Weight { - return model.Name - } - randomVal -= *model.Weight - } - return "" -} - func IsCritical(model *v1alpha2.InferenceModel) bool { if model.Spec.Criticality != nil && *model.Spec.Criticality == v1alpha2.Critical { return true diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index f60a4cc9..1a88e5dc 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -30,7 +30,6 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" testutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) @@ -223,113 +222,6 @@ func TestModel(t *testing.T) { } } -func TestRandomWeightedDraw(t *testing.T) { - logger := logutil.NewTestLogger() - tests := []struct { - name string - model *v1alpha2.InferenceModel - want string - }{ - { - name: "'random' distribution", - model: &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - Weight: pointer(50), - }, - { - Name: "v1", - Weight: pointer(50), - }, - }, - }, - }, - want: "canary", - }, - { - name: "'random' distribution", - model: &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - Weight: pointer(25), - }, - { - Name: "v1.1", - Weight: pointer(55), - }, - { - Name: "v1", - Weight: pointer(50), - }, - }, - }, - }, - want: "v1", - }, - { - name: "'random' distribution", - model: &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - Weight: pointer(20), - }, - { - Name: "v1.1", - Weight: pointer(20), - }, - { - Name: "v1", - Weight: pointer(10), - }, - }, - }, - }, - want: "v1.1", - }, - { - name: "weighted distribution with weight unset", - model: &v1alpha2.InferenceModel{ - Spec: v1alpha2.InferenceModelSpec{ - TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - }, - { - Name: "v1.1", - }, - { - Name: "v1", - }, - }, - }, - }, - want: "canary", - }, - } - var seedVal int64 = 420 - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - for range 10000 { - model := RandomWeightedDraw(logger, test.model, seedVal) - if model != test.want { - t.Errorf("Model returned: %v != %v", model, test.want) - break - } - } - }) - } -} - -func pointer(v int32) *int32 { - return &v -} - var ( pod1 = &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index 12afe4d7..d7678fad 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -69,7 +69,7 @@ func (s *Server) HandleRequestBody( return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} } if len(modelObj.Spec.TargetModels) > 0 { - modelName = datastore.RandomWeightedDraw(logger, modelObj, 0) + modelName = RandomWeightedDraw(logger, modelObj, 0) if modelName == "" { return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index 1452fdd2..79ad7a6a 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -85,9 +85,7 @@ func (s *Server) HandleResponseHeaders( if header.Key == "content-type" { contentType := header.RawValue if strings.Contains(string(contentType), "text/event-stream") { - reqCtx.Streaming = true - } else { - reqCtx.Streaming = false + reqCtx.modelServerStreaming = true } typeFound = true } @@ -155,7 +153,7 @@ func (s *Server) HandleResponseBody( loggerVerbose := logger.V(logutil.VERBOSE) body := req.Request.(*extProcPb.ProcessingRequest_ResponseBody) - if reqCtx.Streaming { + if reqCtx.modelServerStreaming { logger.V(logutil.DEBUG).Info("Processing HandleResponseBody") if err := s.HandleStreaming(ctx, reqCtx, body, loggerVerbose); err != nil { return nil, err @@ -189,7 +187,7 @@ func (s *Server) HandleNonStreaming( if err := json.Unmarshal(body.ResponseBody.Body, &res); err != nil { return errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("unmarshaling response body: %v", err)} } - reqCtx.Response = res + reqCtx.Usage = res.Usage reqCtx.ResponseSize = len(body.ResponseBody.Body) reqCtx.ResponseComplete = true loggerVerbose.Info("Response generated", "response", res) @@ -205,7 +203,7 @@ func (s *Server) HandleStreaming( responseText := string(body.ResponseBody.Body) if strings.Contains(responseText, streamingEndMsg) { parsedResp := ParseRespForUsage(ctx, responseText, loggerVerbose) - reqCtx.Response = parsedResp + reqCtx.Usage = parsedResp.Usage } if body.ResponseBody.EndOfStream { diff --git a/pkg/epp/handlers/response_test.go b/pkg/epp/handlers/response_test.go index 8b6f16a7..edfa3edb 100644 --- a/pkg/epp/handlers/response_test.go +++ b/pkg/epp/handlers/response_test.go @@ -65,7 +65,7 @@ func TestHandleResponseBody(t *testing.T) { name string req *extProcPb.ProcessingRequest_ResponseBody reqCtx *RequestContext - want Response + want Usage wantErr bool }{ { @@ -75,12 +75,10 @@ func TestHandleResponseBody(t *testing.T) { Body: []byte(body), }, }, - want: Response{ - Usage: Usage{ - PromptTokens: 11, - TotalTokens: 111, - CompletionTokens: 100, - }, + want: Usage{ + PromptTokens: 11, + TotalTokens: 111, + CompletionTokens: 100, }, }, { @@ -100,7 +98,7 @@ func TestHandleResponseBody(t *testing.T) { }, }, reqCtx: &RequestContext{ - Streaming: true, + modelServerStreaming: true, }, wantErr: false, // In the middle of streaming response, so request context response is not set yet. @@ -113,15 +111,13 @@ func TestHandleResponseBody(t *testing.T) { }, }, reqCtx: &RequestContext{ - Streaming: true, + modelServerStreaming: true, }, wantErr: false, - want: Response{ - Usage: Usage{ - PromptTokens: 7, - TotalTokens: 17, - CompletionTokens: 10, - }, + want: Usage{ + PromptTokens: 7, + TotalTokens: 17, + CompletionTokens: 10, }, }, } @@ -141,7 +137,7 @@ func TestHandleResponseBody(t *testing.T) { return } - if diff := cmp.Diff(test.want, reqCtx.Response); diff != "" { + if diff := cmp.Diff(test.want, reqCtx.Usage); diff != "" { t.Errorf("HandleResponseBody returned unexpected response, diff(-want, +got): %v", diff) } }) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 4f45ae82..cd354c2f 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -128,10 +128,10 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { reqCtx.ResponseCompleteTimestamp = time.Now() metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) - metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.PromptTokens) - metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Response.Usage.CompletionTokens) + metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) + metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) } - if reqCtx.Streaming { + if reqCtx.modelServerStreaming { logger.V(logutil.DEBUG).Info("Request context after HandleResponseBody", "context", reqCtx) } else { loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) @@ -149,7 +149,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { } } - if !reqCtx.Streaming { + if !reqCtx.modelServerStreaming { loggerVerbose.Info("Response generated", "response", resp) } else { logger.V(logutil.DEBUG).Info("Response generated", "response", resp) @@ -224,9 +224,32 @@ type RequestContext struct { RequestReceivedTimestamp time.Time ResponseCompleteTimestamp time.Time RequestSize int - Response Response + Usage Usage ResponseSize int ResponseComplete bool ResponseStatusCode string - Streaming bool + + RequestState StreamRequestState + modelServerStreaming bool + + reqHeaderResp *extProcPb.ProcessingResponse + reqBodyResp *extProcPb.ProcessingResponse + reqTrailerResp *extProcPb.ProcessingResponse + + respHeaderResp *extProcPb.ProcessingResponse + respBodyResp *extProcPb.ProcessingResponse + respTrailerResp *extProcPb.ProcessingResponse } + +type StreamRequestState int + +const ( + RequestReceived StreamRequestState = 0 + HeaderRequestResponseComplete StreamRequestState = 1 + BodyRequestResponsesComplete StreamRequestState = 2 + TrailerRequestResponsesComplete StreamRequestState = 3 + ResponseRecieved StreamRequestState = 4 + HeaderResponseResponseComplete StreamRequestState = 5 + BodyResponseResponsesComplete StreamRequestState = 6 + TrailerResponseResponsesComplete StreamRequestState = 7 +) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 684a7542..64f9c03b 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 handlers import ( @@ -5,6 +21,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "strconv" "strings" "time" @@ -16,6 +33,8 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" @@ -51,13 +70,13 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // Create request context to share states during life time of an HTTP request. // See https://github.com/envoyproxy/envoy/issues/17540. - reqCtx := &StreamingRequestContext{ + reqCtx := &RequestContext{ RequestState: RequestReceived, } var body []byte - var requestBody, responseBody map[string]interface{} + // Create error handling var as each request should only report once for // error metrics. This doesn't cover the error "Cannot receive stream request" because // such errors might happen even though response is processed. @@ -90,8 +109,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) switch v := req.Request.(type) { case *extProcPb.ProcessingRequest_RequestHeaders: - reqCtx.RequestReceivedTimestamp = time.Now() - // Do nothing. Header info is handled in the HandleRequestBody func + err = s.HandleRequestHeaders(ctx, reqCtx, v) case *extProcPb.ProcessingRequest_RequestBody: loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) // In the stream case, we can receive multiple request bodies. @@ -237,7 +255,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // updateStateAndSendIfNeeded checks state and can send mutiple responses in a single pass, but only if ordered properly. // Order of requests matter in FULL_DUPLEX_STREAMING. For both request and response, the order of response sent back MUST be: Header->Body->Trailer, with trailer being optional. -func (r *StreamingRequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, loggerVerbose logr.Logger) error { +func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, loggerVerbose logr.Logger) error { // No switch statement as we could send multiple responses in one pass. if r.RequestState == RequestReceived && r.reqHeaderResp != nil { loggerVerbose.Info("Request header response", "obj", r.reqHeaderResp) @@ -291,51 +309,13 @@ func (r *StreamingRequestContext) updateStateAndSendIfNeeded(srv extProcPb.Exter return nil } -type StreamingRequestContext struct { - TargetPod string - TargetEndpoint string - Model string - ResolvedTargetModel string - RequestState StreamRequestState - RequestReceivedTimestamp time.Time - ResponseCompleteTimestamp time.Time - RequestSize int - Usage Usage - ResponseSize int - ResponseComplete bool - ResponseStatusCode string - - modelServerStreaming bool - - reqHeaderResp *extProcPb.ProcessingResponse - reqBodyResp *extProcPb.ProcessingResponse - reqTrailerResp *extProcPb.ProcessingResponse - - respHeaderResp *extProcPb.ProcessingResponse - respBodyResp *extProcPb.ProcessingResponse - respTrailerResp *extProcPb.ProcessingResponse -} - -type StreamRequestState int - -const ( - RequestReceived StreamRequestState = 0 - HeaderRequestResponseComplete StreamRequestState = 1 - BodyRequestResponsesComplete StreamRequestState = 2 - TrailerRequestResponsesComplete StreamRequestState = 3 - ResponseRecieved StreamRequestState = 4 - HeaderResponseResponseComplete StreamRequestState = 5 - BodyResponseResponsesComplete StreamRequestState = 6 - TrailerResponseResponsesComplete StreamRequestState = 7 -) - // HandleRequestBody always returns the requestContext even in the error case, as the request context is used in error handling. func (s *StreamingServer) HandleRequestBody( ctx context.Context, - reqCtx *StreamingRequestContext, + reqCtx *RequestContext, req *extProcPb.ProcessingRequest, requestBodyMap map[string]interface{}, -) (*StreamingRequestContext, error) { +) (*RequestContext, error) { var requestBodyBytes []byte logger := log.FromContext(ctx) loggerVerbose := logger.V(logutil.VERBOSE) @@ -357,7 +337,7 @@ func (s *StreamingServer) HandleRequestBody( return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} } if len(modelObj.Spec.TargetModels) > 0 { - modelName = datastore.RandomWeightedDraw(logger, modelObj, 0) + modelName = RandomWeightedDraw(logger, modelObj, 0) if modelName == "" { return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } @@ -405,63 +385,8 @@ func (s *StreamingServer) HandleRequestBody( reqCtx.TargetPod = targetPod.NamespacedName.String() reqCtx.TargetEndpoint = endpoint - headers := []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: s.destinationEndpointHintKey, - RawValue: []byte(endpoint), - }, - }, - // We need to update the content length header if the body is mutated, see Envoy doc: - // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto - { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte(strconv.Itoa(len(requestBodyBytes))), - }, - }, - } - // Print headers for debugging - for _, header := range headers { - logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) - } + s.populateRequestHeaderResponse(ctx, reqCtx, endpoint, len(requestBodyBytes)) - targetEndpointValue := &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.destinationEndpointHintKey: { - Kind: &structpb.Value_StringValue{ - StringValue: endpoint, - }, - }, - }, - } - dynamicMetadata := targetEndpointValue - if s.destinationEndpointHintMetadataNamespace != "" { - // If a namespace is defined, wrap the selected endpoint with that. - dynamicMetadata = &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.destinationEndpointHintMetadataNamespace: { - Kind: &structpb.Value_StructValue{ - StructValue: targetEndpointValue, - }, - }, - }, - } - } - - reqCtx.reqHeaderResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestHeaders{ - RequestHeaders: &extProcPb.HeadersResponse{ - Response: &extProcPb.CommonResponse{ - ClearRouteCache: true, - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: headers, - }, - }, - }, - }, - DynamicMetadata: dynamicMetadata, - } reqCtx.reqBodyResp = &extProcPb.ProcessingResponse{ // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header // and as an unstructure ext-proc response metadata key/value pair. This enables different integration @@ -487,9 +412,9 @@ func (s *StreamingServer) HandleRequestBody( // HandleResponseBody always returns the requestContext even in the error case, as the request context is used in error handling. func (s *StreamingServer) HandleResponseBody( ctx context.Context, - reqCtx *StreamingRequestContext, + reqCtx *RequestContext, response map[string]interface{}, -) (*StreamingRequestContext, error) { +) (*RequestContext, error) { logger := log.FromContext(ctx) loggerVerbose := logger.V(logutil.VERBOSE) loggerVerbose.Info("Processing HandleResponseBody") @@ -541,7 +466,7 @@ func (s *StreamingServer) HandleResponseBody( // The function is to handle streaming response if the modelServer is streaming. func (s *StreamingServer) HandleResponseBodyModelStreaming( ctx context.Context, - reqCtx *StreamingRequestContext, + reqCtx *RequestContext, responseText string, ) { logger := log.FromContext(ctx) @@ -554,3 +479,124 @@ func (s *StreamingServer) HandleResponseBodyModelStreaming( metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.CompletionTokens) } } + +func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *RequestContext, req *extProcPb.ProcessingRequest_RequestHeaders) error { + reqCtx.RequestReceivedTimestamp = time.Now() + + // an EoS in the request headers means this request has no body or trailers. + if req.RequestHeaders.EndOfStream { + // We will route this request to a random pod as this is assumed to just be a GET + // More context: https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/526 + // The above PR will address endpoint admission, but currently any request without a body will be + // routed to a random upstream pod. + pod := GetRandomPod(s.datastore) + pool, err := s.datastore.PoolGet() + if err != nil { + return err + } + endpoint := pod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) + s.populateRequestHeaderResponse(ctx, reqCtx, endpoint, 0) + } + return nil +} + +func (s *StreamingServer) populateRequestHeaderResponse(ctx context.Context, reqCtx *RequestContext, endpoint string, requestBodyLength int) { + logger := log.FromContext(ctx) + headers := []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: s.destinationEndpointHintKey, + RawValue: []byte(endpoint), + }, + }, + } + if requestBodyLength > 0 { + // We need to update the content length header if the body is mutated, see Envoy doc: + // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto + headers = append(headers, &configPb.HeaderValueOption{ + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(requestBodyLength)), + }, + }) + } + // Print headers for debugging + for _, header := range headers { + logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) + } + + targetEndpointValue := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + } + dynamicMetadata := targetEndpointValue + if s.destinationEndpointHintMetadataNamespace != "" { + // If a namespace is defined, wrap the selected endpoint with that. + dynamicMetadata = &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: targetEndpointValue, + }, + }, + }, + } + } + + reqCtx.reqHeaderResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: headers, + }, + }, + }, + }, + DynamicMetadata: dynamicMetadata, + } +} + +func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed int64) string { + // TODO: after we are down to 1 server implementation, make these methods a part of the struct + // and handle random seeding on the struct. + source := rand.NewSource(rand.Int63()) + if seed > 0 { + source = rand.NewSource(seed) + } + r := rand.New(source) + + // all the weight values are nil, then we should return random model name + if model.Spec.TargetModels[0].Weight == nil { + index := r.Int31n(int32(len(model.Spec.TargetModels))) + return model.Spec.TargetModels[index].Name + } + + var weights int32 + for _, model := range model.Spec.TargetModels { + weights += *model.Weight + } + logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) + randomVal := r.Int31n(weights) + // TODO: optimize this without using loop + for _, model := range model.Spec.TargetModels { + if randomVal < *model.Weight { + return model.Name + } + randomVal -= *model.Weight + } + return "" +} + +func GetRandomPod(ds datastore.Datastore) *backendmetrics.Pod { + pods := ds.PodGetAll() + number := rand.Intn(len(pods)) + pod := pods[number] + return pod.GetPod() +} diff --git a/pkg/epp/handlers/streamingserver_test.go b/pkg/epp/handlers/streamingserver_test.go new file mode 100644 index 00000000..72f7031a --- /dev/null +++ b/pkg/epp/handlers/streamingserver_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2025 The Kubernetes 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 handlers + +import ( + "testing" + + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func TestRandomWeightedDraw(t *testing.T) { + logger := logutil.NewTestLogger() + tests := []struct { + name string + model *v1alpha2.InferenceModel + want string + }{ + { + name: "'random' distribution", + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ + { + Name: "canary", + Weight: pointer(50), + }, + { + Name: "v1", + Weight: pointer(50), + }, + }, + }, + }, + want: "canary", + }, + { + name: "'random' distribution", + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ + { + Name: "canary", + Weight: pointer(25), + }, + { + Name: "v1.1", + Weight: pointer(55), + }, + { + Name: "v1", + Weight: pointer(50), + }, + }, + }, + }, + want: "v1", + }, + { + name: "'random' distribution", + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ + { + Name: "canary", + Weight: pointer(20), + }, + { + Name: "v1.1", + Weight: pointer(20), + }, + { + Name: "v1", + Weight: pointer(10), + }, + }, + }, + }, + want: "v1.1", + }, + { + name: "weighted distribution with weight unset", + model: &v1alpha2.InferenceModel{ + Spec: v1alpha2.InferenceModelSpec{ + TargetModels: []v1alpha2.TargetModel{ + { + Name: "canary", + }, + { + Name: "v1.1", + }, + { + Name: "v1", + }, + }, + }, + }, + want: "canary", + }, + } + var seedVal int64 = 420 + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for range 10000 { + model := RandomWeightedDraw(logger, test.model, seedVal) + if model != test.want { + t.Errorf("Model returned: %v != %v", model, test.want) + break + } + } + }) + } +} + +func pointer(v int32) *int32 { + return &v +} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index cb18eaa4..b12925ed 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -427,10 +427,10 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 - `}, + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -508,10 +508,10 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `}, + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -589,10 +589,10 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `}, + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -716,10 +716,10 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 - `}, + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -824,10 +824,10 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 - `}, + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -932,10 +932,10 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="direct-model",target_model_name="direct-model"} 1 - `}, + # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. + # TYPE inference_model_request_total counter + inference_model_request_total{model_name="direct-model",target_model_name="direct-model"} 1 + `}, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -1234,7 +1234,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} -data: [DONE]`, + data: [DONE]`, ), EndOfStream: false}, }, @@ -1249,31 +1249,31 @@ data: [DONE]`, }, wantErr: false, wantMetrics: map[string]string{`inference_model_input_tokens`: ` - # HELP inference_model_input_tokens [ALPHA] Inference model input token count distribution for requests in each model. - # TYPE inference_model_input_tokens histogram - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1"} 0 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="8"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="16"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="32"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="64"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="128"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="256"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="512"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1024"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="2048"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="4096"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="8192"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="16384"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="32778"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="65536"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="131072"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="262144"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="524288"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1.048576e+06"} 1 - inference_model_input_tokens_bucket{model_name="",target_model_name="",le="+Inf"} 1 - inference_model_input_tokens_sum{model_name="",target_model_name=""} 7 - inference_model_input_tokens_count{model_name="",target_model_name=""} 1 - `}, + # HELP inference_model_input_tokens [ALPHA] Inference model input token count distribution for requests in each model. + # TYPE inference_model_input_tokens histogram + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1"} 0 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="8"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="16"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="32"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="64"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="128"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="256"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="512"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1024"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="2048"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="4096"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="8192"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="16384"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="32778"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="65536"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="131072"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="262144"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="524288"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="1.048576e+06"} 1 + inference_model_input_tokens_bucket{model_name="",target_model_name="",le="+Inf"} 1 + inference_model_input_tokens_sum{model_name="",target_model_name=""} 7 + inference_model_input_tokens_count{model_name="",target_model_name=""} 1 + `}, wantResponses: []*extProcPb.ProcessingResponse{ { Response: &extProcPb.ProcessingResponse_ResponseHeaders{ @@ -1381,7 +1381,7 @@ data: [DONE]`, Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} -data: [DONE]`, + data: [DONE]`, ), EndOfStream: false, }, @@ -1409,6 +1409,63 @@ data: [DONE]`, }, }, }, + // Bodyless Request test + { + name: "simple GET Request", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "content-type", + RawValue: []byte("text/event-stream"), + }, + { + Key: "status", + RawValue: []byte("200"), + }, + }, + }, + EndOfStream: true, + }, + }, + }, + }, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-gateway-destination-endpoint", + RawValue: []byte("192.168.1.1:8000"), + }, + }, + }}, + }, + }, + }, + DynamicMetadata: makeMetadata("192.168.1.1:8000"), + }, + }, + pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + fakePod(0): { + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + }, + }, + }, } for _, test := range tests { From 7bbb8369d30eb4a33ffca5fda2693905d6b0da5c Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 24 Mar 2025 13:52:33 -0700 Subject: [PATCH 144/260] remove controller-runtime dependency from API (#565) --- api/v1alpha2/groupversion_info.go | 45 ----------------- api/v1alpha2/inferencemodel_types.go | 4 -- api/v1alpha2/inferencepool_types.go | 4 -- api/v1alpha2/zz_generated.register.go | 71 +++++++++++++++++++++++++++ hack/update-codegen.sh | 4 ++ 5 files changed, 75 insertions(+), 53 deletions(-) delete mode 100644 api/v1alpha2/groupversion_info.go create mode 100644 api/v1alpha2/zz_generated.register.go diff --git a/api/v1alpha2/groupversion_info.go b/api/v1alpha2/groupversion_info.go deleted file mode 100644 index f9eb9b1e..00000000 --- a/api/v1alpha2/groupversion_info.go +++ /dev/null @@ -1,45 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 v1alpha2 contains API Schema definitions for the gateway v1alpha2 API group -// +kubebuilder:object:generate=true -// +groupName=inference.networking.x-k8s.io -package v1alpha2 - -import ( - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/scheme" -) - -var ( - // GroupVersion is group version used to register these objects - GroupVersion = schema.GroupVersion{Group: "inference.networking.x-k8s.io", Version: "v1alpha2"} - - // SchemeGroupVersion is alias to GroupVersion for client-go libraries. - // It is required by pkg/client/informers/externalversions/... - SchemeGroupVersion = GroupVersion - - // SchemeBuilder is used to add go types to the GroupVersionKind scheme - SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} - - // AddToScheme adds the types in this group-version to the given scheme. - AddToScheme = SchemeBuilder.AddToScheme -) - -// Resource is required by pkg/client/listers/... -func Resource(resource string) schema.GroupResource { - return GroupVersion.WithResource(resource).GroupResource() -} diff --git a/api/v1alpha2/inferencemodel_types.go b/api/v1alpha2/inferencemodel_types.go index c011031e..d80bd556 100644 --- a/api/v1alpha2/inferencemodel_types.go +++ b/api/v1alpha2/inferencemodel_types.go @@ -223,7 +223,3 @@ const ( // ModelReasonPending is the initial state, and indicates that the controller has not yet reconciled the InferenceModel. ModelReasonPending InferenceModelConditionReason = "Pending" ) - -func init() { - SchemeBuilder.Register(&InferenceModel{}, &InferenceModelList{}) -} diff --git a/api/v1alpha2/inferencepool_types.go b/api/v1alpha2/inferencepool_types.go index b411dbe3..7018ba21 100644 --- a/api/v1alpha2/inferencepool_types.go +++ b/api/v1alpha2/inferencepool_types.go @@ -244,7 +244,3 @@ const ( // or API group, or a reference to a resource that can not be found. InferencePoolReasonInvalidExtensionRef InferencePoolReason = "InvalidExtensionRef" ) - -func init() { - SchemeBuilder.Register(&InferencePool{}, &InferencePoolList{}) -} diff --git a/api/v1alpha2/zz_generated.register.go b/api/v1alpha2/zz_generated.register.go new file mode 100644 index 00000000..3c2732a5 --- /dev/null +++ b/api/v1alpha2/zz_generated.register.go @@ -0,0 +1,71 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2024 The Kubernetes 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. +*/ +// Code generated by register-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName specifies the group name used to register the objects. +const GroupName = "inference.networking.x-k8s.io" + +// GroupVersion specifies the group and the version used to register the objects. +var GroupVersion = v1.GroupVersion{Group: GroupName, Version: "v1alpha2"} + +// SchemeGroupVersion is group version used to register these objects +// Deprecated: use GroupVersion instead. +var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha2"} + +// Resource takes an unqualified resource and returns a Group qualified GroupResource +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +var ( + // localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes. + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + // Deprecated: use Install instead + AddToScheme = localSchemeBuilder.AddToScheme + Install = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Adds the list of known types to Scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &InferenceModel{}, + &InferenceModelList{}, + &InferencePool{}, + &InferencePoolList{}, + ) + // AddToGroupVersion allows the serialization of client types like ListOptions. + v1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index c825507b..ab5818fa 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -30,6 +30,10 @@ kube::codegen::gen_helpers \ --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \ "${SCRIPT_ROOT}" +kube::codegen::gen_register \ + --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \ + "${SCRIPT_ROOT}" + kube::codegen::gen_client \ --with-watch \ --with-applyconfig \ From 71c9dd786215b1eb4e3b6e3856752cf3d40a6722 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 24 Mar 2025 14:08:33 -0700 Subject: [PATCH 145/260] swapping out flow image (#562) --- site-src/images/request-flow.png | Bin 153112 -> 82689 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/site-src/images/request-flow.png b/site-src/images/request-flow.png index ee2bf2269ac0099c9ef62287369c0b713937b970..a010038a540b709d3b95a7e54226fbfec28a26e0 100644 GIT binary patch literal 82689 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 literal 153112 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

{M>W#Ws#9Mb<6cvK_7a))Q<9q&|nyY}B%05h_J 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%0DQS9E$wJ*SypFpyrgV@D-Q2WCuTQMX` zSbhdyb7~029HyGM?Kp%vmdxK5UgCBN*^IDRq7Jqds>mh2YqkZ^>`OG0Ef#C*en~oX zcOy~i5*&vtNRF~qLs)4N_FOulYwO;*K%Ewu+v96 z-wxwiWx9+gFJkkQ=3G0Z4Tw>PPuYxvgQH2n20=$Rk36~dixR;b{-<#RsJHQs+Mcs5 zj7bME`s*K9k-fg!-{~Y4`Z*F1%098v3t1E3DIg@+kMCuT)fgfyV(c)~Hz__;HJ5E| zE_U7-@5+|sh4#vkz1`TpGw-|HR4n9d!Hc_b%qUWM%kwgI)M?ZGyV9lBS&K9JbBhQ` za=|ELrm(E&(A({w@Ak`+6yxtK#%y{^d~n5MK4-_y8b=235MG6AGsV+bAhG$4`aec--~zEtMK=Mg4vi;p8mQxX!$jFKe-@{X?o*x-iVE#Q7N)( zb1R$!R<~V}dmblQG}lBw#L~=FQWY5HKcO~U!Cs4F)a>B9kA-k3Zo|dQV=4P^h>Kxc zRi*H+9J#Vp7)~Le+b9XAD+9;tL#=WgnnK#+WiXeS3~Jb2JSF=PNgdG>6Yi_7!_`*{ zP`dZ(<&!ZeQIPIrQ&8ySqYhZxg%1OGsV9rhn)!uxGe0IBv4fF)<8^C(=S}RZ*pvsi z8jlv?W(IAFMrH01PCj|&XeR8{s1TXf5+n5IVAKSv8v`K$kWZYQm@)ezg(HGi{ z#f=mKsyoE~T_NCR9tJImLACaVWx~1!pzxL`e>nMLM6YO48Qm;nYU$nO`B6IKn$)6M z*sy57*u4v2zE37P5$0o1QAipliV$Q%FFp_12981+yV-;MG0QRJ%ByOgKrdQ$J63fm zJ`2dBW8*e`qSL8}`#7xfdn94Slk_>*@tq+aUyg#Idv7V7Bys>BUg0~545A$d3U>Pb zZGV1y1t?(fNyct90M68dB(f3f6(Ryqdv-0jij=B4KoN3ST5qtjSZ`S|_kh0m!KPye ze{AYBWePc3FoYfJ&QCbFt5|Qu2ZCz zo)Pkpy3-Z>En0`RjSg38b_CCX=Ir=)nT{O?u+`#2Z=@pfF9s0A(%la_&>?p7z{@S5 zswEH~W8S7q)9-M)07%3B&#DyuS(Lhdeyx*@Uo#^l2-fwvo1%=HJcg7ojinmV%cXd% z551D(KqI`Z+PQ0#B9s|`OqLt{MKUqZ?Iggsc8>K)kKp3WCI>24$Ou5Gw$g?H^=wq z`5#v3XnNj0{|Y?DHZkXp5`2&~EmaAC{lo9V5pdgI<=`vv>!su8*DPWZ*hg9^2yvJD zUSx$3L|Jv-#Be~sh=YBi-DlztVDptZO&_r=&^<_Y6e%>Dau?0xsNtA1Wp4}24N$Fa z_0$-yw8KNj7I=MO!f^sX-Z-1aBjBE#ns0;-=cyDoA4= z|5*~?fkdz6E89s5(HcoUD;1HKKz8JA(#>r1fTjWBedX7r+>e-iN!ib9^Ieh~f`U|S znUvl^6uh8K2T=^`i+s;K62T~Ep5$M<@(T0*>63Kkx526LG0Ix+DcZ`Tis`$f5elgy|nN_ zn;eJz;3=k$xLeIlc7f8}cJVKze6h;OCCA^0;;1t;5jt##FSz+UT|XyP?E?UM!VrZ$ zS-Rq2YKnfM^2yvm;!{v(J-KNuKFWQ2YIzP}%y2&)5CN#n&8X$gLd?OtyC{ENv*75w z_zkrdRA3*d`#3a-D1+t>BkL0&!bznK8ZuWGMSrW}+$z3%K2r;ic3Bx5{WB&4zOa|a z;TM&P9!{`66pa-st2;<^nKG>01P?2Idn^e6cl-5V2B%s~dF6O=> zW?_=VOtBZdhO~MDT0E$c@a475uQ)G;wRyky=|_VlDX~9Cw@GToLG*RKsE#a}#^XEs zq1<^NxoZcR+A5eaxj5%P8$sF|Oa1vN3~G&rz(82ukq=S%Dk~V-s_icg@g7nRf~P^q zAQIX?mS92m+y}Icz(B|v$)+tavew9m06A-UEs)8-T)x(i{8?=aaAe8sd2+!x$z4^3 zU%-QnOLYEjKvI@&q~6?Xv{8?=ao~%#Zj!XC4&z_YwlT^Le^0mSiZ_fOa8a?WYWWqf z?%PTvQRDWz-KxN2#Ta<5zNb@xX_mou?<-U*)EBZ8EC=nRvG62D@Nz~|RTlmO!9Y)q zQDbu{ZGZ$Y^Bu=gMVc8bF2{0pg=fyCu-log3t|_d)v2JLzp4Z}$UybKlnwFXF&i-- zf~B|p9geL3IahW@_H$?FSQ?bnf*{oA)y-IxvohDn1#$;~{Yt0c!gK&qJNXPVaQv^%a}+NMs1&Pd9>cY|@U-BOL_ zf#f=Z$ocwP;@lHoE+RJm>4hE!Raz5KrR1+6TkvG$U4J0PU2fz3QYTybRxA@9$huRV zgFpMe1(v0gppHc@6WeO>I|PQwZsrC?&am^S`CR%k-=9YZSXiG-5da=%(IVOs7r^YE z;g1oQ;;`b7;HqCLIDZ5D7c#5tug(Q%6)w^d-5SW{zA~MU*5I7 zk=D^nnkRrB5zR`PYj$_J7*=gi&O}j($LYD+`2Sma!qO-vqbXW17_naiPj};Z>u{x zCRg9Z+5`FHj;&OxE^=0s1l#H#;HB@0{l6DB^ojpZHGAt<_{aVY;twuhReP&prEVKO zVYBM+uOFV~h~38)B?4UIR!fj?I`TmHj&Au0lb7k^lYJ`ycPv0$guyzAFk)ukhD@8K zK%_QZ5J8;Q>D!pbrHG;=46_EQGq$Y$R*8!!UfX@F&kS$EmHPc3r`D?L@&3Fj`-8(C z?S*_JP|SLgED>HD6f9zB@;?6-@|w~Sh$+q%C(Ox+UB(9y@~gn?30u+4GdWohfvjBh zW&AO;B;HP&o$zP2XF``}3vLnt+Ej*>%FjWLiU7rn!_<^LAlSM~t2zSQ6m!1k^0}{v zX)IvW5(nv)rA))l!OsKsB`-~1u?*e8gQ#9kUTQ5pb~9Np-~Hshsfy$IHVpRaMR*^1cE)! zHd{u5VfQm0sND6E#AX0%F&a~hAa5`z@x?ya1PD_CF?l@IaYIlXUh7z}Y7Jl+bCk4G zgdaVCoQ)JFZ6ujSdBRSHzTe1T#N;LUW*9H2KnkDQ5lDO03S1iWaWP25G%Df__vqg6 zuQr=M9b+oS;@TN!AX%>9*gK?H9YqgJAv@x`rm~i83l5l@)xqh%wg-_GG7*o_ORAs5 z?QkP_RH2c=L0QoOF>$jxy+>`t!baw{ktqJ_&{ms3rWl{6V8Zmd6OA$A0nPJHPP;k)BF!6;y17}af+RyacJL)&jNoLk9EiezH*yZZT-mTdbz5H%B zXDBJ;4K)sJS0w8UFxmfbo%v(1uJu)&3PK$qcqb!GIhT{n2;z4k3YN-}1MlVMa1u9* zd~v%+&KtLvI}Dt(#>a2k!t7zuB|^LYX5@WQx{L&Hs=?kflOIT;710i)W6AvjPG(u- z2wqTR)0vzD)mqOs@qCklv=!@-VcT}YQxJET5Cfg#<)NmmSOT*$#MLShT|7a<_M^kF z+`weCrDkUsw^hIB5=~YAe2Xj-2?>sCNcP8gL8`yc;;osRcpCQI9}_)DInaf5JjK5f zAxkp%6JH}5Ebdg$F%dD6vE7$R?f7o4PMn{6$E34TLs%abBhrRd@6rJe#p0zL`+mKC{O;zO>mKmVe6%J z(sKmez`|r^tM4L(3vAn+PDU+sby^<5^cjkj^EWstUu^-h^Bmfl-1ZYpZ)bJKL9CFG+D1Ij{>c7o3qIbd?yVYk`1dzNeFh*E`~VbG%g0 z_e7P5-G}g$yRY5}Wwhg_ftI`^(mRQtDZf>1EN-WF4i*W>6TatGnP8qR;L7`m;LeJV zk;b{V*dPK@mn5_01{E7{1+oM9p2wZM{=fsEoB#1 z5AQZZUBmRrBmKOD)KC61sXvE>|NBs7>#N!*-+_@KndF}@*%MYO9^?e80lQ#95(M@V z=-1HReIG`|Oa~v)I=E1*g`is4Ikk z&rv`TKjKU%Z$|=wkPKx~WA3;L_wscB29KSEVBe0a5%M$~EZ3O$YTzS9OGm7>dK{^YHo%&gJIlYTRCzqrn;0_P1sZey{4{S+>fpYs7qHAxz7UzxmI52J`ht~U&10x}mjiDI+D0jw1 zj4RS~N%Wjn$|;qo_m7jVw!2sO)r>mhzq_7B-6a=3x;*Yqv2FDU+o)S?F2 z`nL%tLpC!C@B9+@ISSoOnZ0Ceik=S{CfidMRBaHk!nh|YI9ozKV=VE};B(%sG> z8<}(a3r-a$Y2GmsV$2|>869mYx3`uMS?YOO{uz|d(3b*)CUjyh^PxRjPKaMMKRJ*w z$!OwCE*JV6%mlP*M}>HKuc{a+z7o)>;A0j%^V68Hfku=B<52SXWXSB5e*qnrfb573 z9XEPbYJlh~(QaF`YjH+?U=}}B#Nl57eZjIj%m+n|h|;P!XOxz$Nj_Oj;ehYTtj;@X z6o_&Lt=hni1-7;HL8~>6IUy;HW09hIP;?Y4LI~%=AN80V9`m|iB%PtLC}^zvLaGL1 zB z^6kJ@kR9Ve_A+wll6$kB8WiS>{cMZLk_yTeS}n~;1WKFLh1#;SDWqxKmmS$^u33_J ztY*n^@vKMzT<>Smovfc_l1?U`5P{FBKKcp-X4qFM z^m_AM>G&HI4*PIY49UOtSnjDM5i_N|3D$nfp@-pRiiPL5mdG9O_*xuTOO+;}FuhOK z4w;^#i}qeWTp{#^Pz4AujVJm)=mC20belgE5vGVyXv?vcN-1u$+a=IioI{dsSzK2t zMd()}L5+EFT1yf^SmTg1o1gvtKJv^YHgzTL8|}aieWllh(@!EIgQY}-&3-y0H8;BL zSuoCW&Zf6-m+EL9ud(aOW;1fC;3N{0ojw4As^m(gDD!N6~LiP`5DE;mmFD)x^Vd%q)22zcz;2dkwyy7apM zK$+Ph>N+Z8f<&zk97jA~QFrngp+#9I4+rE0`PX(W)F4c^Uy-181@AH)pVC|AjEp*sXc7;u8&*_>>tuv@fFAqG^UwbrcVcjrgJIKWadQw{Jssf&> zF6O7{CasZBB3XQ!KkI;hC(|#gBpD&3wjDO&t07mggiYm?-X{{t@%jB-YpdUo)+pAX zM^d)zik59XursFVC%(DC-tZ0D$HSv3&I6!D6eJ7QQ!u&;m494Jkg5?EZSks<-OzjO z&uFb$^zBPVqMm^Mof%m4mnPj+d$ZoYV9sKd55w=DglUKb92jo(pM*iXS(jI7&6{Jd z47PPwgJ3_^h>6SF8cY=j>eMFQoi;G6 zx^D``a(+(E5LQD|Ju$c@ZI|qMlWVoYO?cYDJNRsiOAk2;Pq*kP|A0%(CTg{?i39%1 z(+QhCK~)suFRdnA@Et>nWw^~z1l!pit(Hj8Lz-VgDzCQ#&@>gvh+zNLn+E;d7-%B2 zs=H{i9>rQy(~2%z<{$_lRgL;*ZUqP%B^6#HiKg9~v2!=+ZjrKDP~iFk8KkQbDp$Xz z@zz1>QjO4LV0P6;`KqSBh!6ew7$^3f;_WrV+{PHiNfD)P5FDFo+P0O!f31E%q}q5Q z;r^O~ebBhqC)I;RL5f}RLie%Q{;ClDAl`fJCq678Ax8v5ka$Hu?#EXs-!)+urz5#R zKNf30=Ob?{3w^b_s9aTw4W8DdZr{~Kih{iMYD%4D_3e);r}CRM52amY$2-YRW-lkgO0;#N!Ks=&~t7&nmASU zF%q}kN*CibPVh1!_x19W}=-++6(JTuOF}2`vHc^~6JG13+M178tQb(|kRCDf7;^kqsTGVCQT>Q< zxJ>(3nhNzf4vlYAFbVse8B=UAi^aw18#c+`B$JVcS{{M~m8eU!7>I!zV zR(hboruxrZd$^PjN1dY)P*b%JvG^qy+VPv|BTZ6=LO+Hh!@Q)t;`7!mbG^Y9dNkcb zq#KV!@l0)j9U}pA7AbZU2V&7Dl%_)OOfpbNmj^?Ten=yDOfHEE3^pcUaWpITU`lfb z^_wTImOGgL;Js&w(s-xKkL3}p>j7BD%u8CiFGfn=UWf>a2*wCTH=A0InB1ii++!n@ zHf*@5N_-Ira9rX#uuOn*TCA_zl3E)NJ^n~<>xwhPsYYzU*sws8l7b}Fbvzi_1-UgfJI!kXG7prhH?(!Bq? zdBnf7W#T(fU7dp@UIVAEk`IPw#_A7=oO;?mH6buzZ4f6?fv-U(owD)d4a8m@{GfJ> zPj5x{ZO5SS_K|7aN(Nk2-h$R)g-|Ez53p`jkNPx35)c3;)B|5MO<}!2AJ{+8cH=q9 z3W$Vg`?t>Hwek0PwJXu#nB9G4r^hD=w{XnJAq%{Xp`ECMxo@^#lrA~gjmRiVak!! zmOfUEnbSEJ*iX}1$8V(GD^;!b9}jZ-Zbqx6?e5*o@ngx5GJJuweUq1$SIBUIVt_+h ze%(zZGD!#v!TkUmW{kaBtPeoF!=tN@|9r$_i5AcKCSJ18Je=n<-#m8FmFhp?(-e;2 zxsS2k#OWBWD!)fVwpQ$JQPWG>*nlM};E>*wI}5N|da}{e0UCy2D~1B)O2sO;fMOsJ z-NxXKmB9Fmhl+;_9e1`ypM4FO4cr)Q>L=pGx708D4ZN?wn#@bY;a6Uv5(+^ zH{cHOlpd%@q)rY~AZgGgF|Cd#s*J12m5weQ_ngV;hm8&T%6^7t_#A*Qo6=Pg)EPgU zi+9vlxdN&XfbQSgg5P^!Tv@%QKt&l(GVhJO!stog8)@Ve9*M)<{Xf?TX}II$*?;;2 z2G#xB7_5Zy?Y8Cb(ukLC_cY~JF3eZ-cPPofvx@l4As2mB`w0^o(Y!mC+2>-VR^(aE z@|$CXA2)irp^KssoX7V?umE@6?m#AkM6e)lofRvfa?PFE1@hW(B_U1Lo=l0^T z`Cu_XD%x;1XLp}N>9I`I1gV^DIYyKkR?|`Z%kP{mBm8P1H~fdDMzc6C4Eh?N%jJ-B zUH26=I7tHyduj(%;Td+TQtESHW(el6r$oq97u2z_cXxIDs2-fgWW1WAC0Jd8gig>f?^FJ*1JBcf*KFaN+9SOq^YkiVB|g#<>en6a8s>s4 z(=1SH$hEGn$aj^bxZaPo=-7&(1M+_@Vmk|$@adv?!Oy!#GMR#EV}cmRqL;AiErrZt zwdJaYIX>v-kNhel4LlHBxqR@3SMbrBgfZg-3|k;ue+K3y=TkTUs#0jPUcB(I<7E8w zc=P|xm4LL}7w_6jJspt=Q|QvE89!Ej+<`u6o~84f?C z!W|;7-$6;49dSjoMS~hmf2%ztGV(0m2%^PH$0Olx;(n+Nx9L>T5VbTPjVwkavMd6%lbS0#Mc z(-KW0{8?!$`x{B!Cow!#9;c?DCBV;zMrrCd{0AC%Wg~c z1sDgmo#O1gjG2Q^D(NeJ3S2!M^E+b{NUT?_)Hw)Br5OA}1EHuve3oAp0w4wa0nTfk zQ&KLD;_rqFtMI@}`4eE$79{zws{hD0yu4m5F2lxp4lH*1C$N~$s+}xn{e`3@3(>r`p+SIRz3ndxl+4a&4Kj9CnzrEpzCTw(fk{%Fa(9eoRD%9I=1{E z*A4`X->e}Xw?F!ZqkGkWv+=KU&);yRa(fBI;+~51A z6cspVERc9j#R)&%AwenOPt|lZRdk#0AfWST#P49gH7gXro!+^Ji$Xk~{*=BdtWu8UJs7pViUBLl0(2G&;#XAP!^VPk{Q> znMALfz!F6w3IJ4W{(thV=>_QD#?eJIn!V>RvF{)`9YA475wA=z+n0Ap7ZU;VaqCTl zHRLcjqUHYSh!$&reoB2S>bKTS{Eku`Al|4$74M5kpV=eK_YMc`w~`Iy?#P|1q#}F` ziLiZAaQtT&snwqMIuN3KBLpZi?1)zgvKR&>mhE@E8g`=!9hPEQ(l1l>@S#=K=RT$X zkGSuS%enpgPKwTo(vIY8YH7KIB$dY5)Gi@SG^~h>P|k)*+LA(x_L32mIz_{bNe z84XfsJntjd^}EM&|NLC{bKkG$pUdm=3g>xzzsGTWKJU+akOKiR>i=R6`agFFkoyf& z0mGB?mnl3F;SrnUm$lR_x<2idj8`t<{e~reAKR7@aW{3QxZ;;HVrysB!$$kN_mzb^ zSC$@kJyY@Vz`*QDy+z=~&CTU1)r)@28`|`B0EmHs!av0e{znh8zwT%V$P^xEV2TfL zU7+mU#j^x9F#2%WT7|B|Ju!EYrUD_&-v_o0Qu+m!Dr7SmK}yg=LdRegB=cN}zQz5= z*N@XZD%=yon&-pD03JxhJuTB-v1}1;lbE%xFL0h*O*QpS6n7S9KN8A>Q)b?=I_Yur zd|z%?D|wJUQs0H&zHs>ut8%Y%?+T^6EHj zbZS*|Y5agJWMk^Dfkb&g?Jr{9-T8l&wDYF}3x#BD!)s90{y*=H{m5`B zhg}fd)7&w^AnqIQK*uQsmIhSE4TP{^qR2;Sk_ld$oR<@3w#*K+$=pI(XvA^MpyxOU z)y`k5S~IVfU;(SUoQ$o21b8sEX2TlII1yrDJRkw>;zV~G#7oj$v^jvII2_}v7Kgfz z?eqDIq?7rdII_Xzt2zW;C-G-piF{gODdaxuJ~q8eHs%2&J-fNFmKdX&B(G>3o>yBb z-bJk0?S)?KM$ym0i6xacW_oK8W!JVD0T|jD#F=evRufnUJEfFGd;sb=rF@lbM+63%BWA`2&wD1s=&n_9}$O)JZF$83L$EqkI8zL=4Jv0X%u(IlJ+$3{jekDLEyd?|mA z!99}LFA*$ySG$Nen6b_yG{Dilz-Z57a(Gm9T>sA&Y9CsvZSdu8Bj#a!xEhPqigCoE zO&WVSZ5o~e`)8dutv9d0*LBCqyrt9T)rXo+&`CTivDYC38{+W-Mo~R*Kh{w(9%5h% zY~y0&Bxr>>d*43Rh7@VhSCy)O>l?0k5?`!n%&Y6*P7{A!$i9zg&;Uwqg3M15@kcQR zq6Z|4XpeEo9d|kL7W2n>cZ6+Df_;&*t7hB@=>B20baL&dD@v-YrsApH-b%umn$bV- zza@4>ptqA6aNdklg867I7K0NQP3CE*tHht|ico_*PJd>t^G^nA4Y4RuS#Tq3A&Ig{ zP5m1Um*TBon%0wdqHWx`QMc`6vp!bXvuDMQX{;^4o@h^P zB?K$FFX9bR`~5d0l-{}vMtU(37~Wy3e5r(snKe@NuF7qa^}$AO4MnnQNLw;v|IP%R zI(q$yqqfn|qO2$S^SifUm3a@E@j%-OI586r68!pP><^Q$OznHD?O*sQFvX{?s1)w5 z)u3v-#Lf{H6LNa!=&V|_uwwuvH(%(<%4cA_*t{SY3@jAJCNn;M4oKhH+of`^bmYy) zP+`P=NH%m|UXl`K@qH-x=5YX#Q! zV28w$@Yxb@0mym5!-P;*V)w&-mKtfHAH3F$jnEH>1Wfyd&J>(qqsj3H5=jr3V{;u8 z2c=nPrDq89jT8ThLwz7OCoH22g*t`afH0Yp7XdafySI4#em8Ck9|dL@>*8n7#hrOJ zUVY`Huk+%uv>GyT=CQ8PCGNg#T5h~YaH4OB1iC`XC<$HZ*mnh)RX45yEvWN`^A zJ86YB*}o+6Yg@x&kw0+_-C+Fbx?OJa%dt$;V~FWGeIM#{QqJ9)fg?u9<_)azAFuqn z<0DA)(DmJiuCKpW6s;2w!wd|she5EBub;bKg{Y;aMl$I$bvnR9@4<1&bGY*3)fYA0 zLo^rySmh*q*cmc+A&Zplu*k*m)21>XDV5_c zPFJ$s3k^Xm)`VTv&AyDvEiT`I^ECs}9jb6y$Ofq-KF6@3)6d-`ulwuHiwRAjmAbyVdIFj7em6GXILT!^|p|LQZBm? zB}u~j?*g#H&WT}bTrR*5cI)Sfl|xG-EfwJgmkiU`qHn`g_eVX{-Ey?8`<#N=-_M|wvF}`2g-F^27XaK|_hEK+v3}krrF5wGZDE#Vx6f~30!`@>YfA0BKJq7?g-Ei{!ucpE0@8oGqySS5 zCB>J4j?JKT6huA?H1T_Cx_rI#5P>NOfP`Esvi|jm!|J^ez$&=JL2||XGK|Rjw{_1J z^`jPrYQWSf=N2(Ds}Js~J7PG0;>|2WbuOcZRib|!Y%93Vh<2xl%%x8 z56xB-;c9{334)_e$IRb)KQY>#t9xyqo(QkNhcP8Ls;+WW^dOXIa!Xy$Yx*mw&_496 zA$%+5=vHDW{V^0&w0~yf1z(LM4&m8P4_Zi?q)?~dDwe;lz!=;cv?p#GFi|$|*n|K4 z_NfpmyBREng|Zq>RZP_u8we+qup=QM=8a>^sDN|BJ>-{>+KuFiyn}y)dW`%w*6`(x zqv&}qmdw@Jf zaa@<8o*C)wi0l~kyTS&G1?6o`+-GPXaI*$6wQOU#pM@dB-NNhV-7dq*Fsc(SsuLTW zN0#nk^aSPNC_QpF2D+%+BR7Tqoj?Ix`=v~>fqK|U(Yv_~<{P}01B+WSY4`R8z{?jn zm_HYXvlc&Dj4HK)2K$ro6`y zK=(6>83A<23UT&90Nu}g1c4&}5V{aR*M#qGlJbuLIxR`^CPB+J4T(%8kEi2)Rfg)$D-RC2^SJ96MD*Y9Uby{aqS+NO}ctUL| zsp`H{h*3+{hmXKsE!Mw-1^Md@8gJi_i4}&HtnVwJgUU7;sN1rcNQSZ1a+J<(!cHM} zK$9u(|920sy)kd3@z%hNrEi%m%RmU_*BbxJ`%#>j3ep2gAgY;v9yi8(=LN0kJV^4= z2b9Ra7@5HYpbib(lXAmYo*Up7WThXslIu?Bn0Bs#$o$vin5n)wsh>!|cHMFFkO49- zKlG`r_UbW`0&SaTFGl=pKy$6H^#2jZX%CIv0r2}X@jJ3#{l^BKzrn?Un9!9t-=0N+ z_Z&W_YO2<+!M)>yP-bJungXvLj~FIRJ}%w{99;_Al;D8{3{Vo1I`ff6Gu$D^Tdw(_ zO5itASok?rxY0ehD~F+G%g#G`gjHwk$wkuz#D=RETsIQhF|rem zATD~6x?dR>Vbp`1FU8HgK+RhQ0eJkhDBP1aE-nyb^j>-9$UANd*Zht;qWc6a5RD;B zJrrD-sy%i&)`kzfAcPwJF>uQmzq+Gtn>L-_Or&TbDVzTqa_qy7*lysxk?e_}-!7*A zu7p{EM@I;B!E;U%Md05AYpHj?A`7OXn1KuFy|u9+4?5)N3Tc(BknIN|`;cC}8ZUVP3^XVzSithO(a zyd(2QaWmz7OWgtTU-Ry}QS-LF%dXYUL zTPJB=NdOZTX6CWhG1$VK-1WTF;ik*Ukkh@WkzTV!KWh0Ht-^7nv3>L}8ADsO*IUeN zXVuN}#f{QGDviM#Ni7^?k%~- zRx%8)x}8RkIiz{Se{2RM8K1WwDvhl46Sq&cqJOi#cnbu18VZ_et?u)gR`+OZD|Z4S zB6U8I6uZB!IU%>e8MY2h=E~OqsO3hSfbtpEgPg>5$i=prI1Ir9#vNowh))-eAjq-p z$a)SCj}%+$OyI-|t*m^bax%tsoFVhy{}qKjhKo7)QRK4YK>UP~!xth_xJ^5L`$6nB z+_eLjJAdZ*n!b61ul`Nw`gRsvz5*6o-+(KjG=&k%)L1u{M_XyOy1;u_1qg`j=5J_5 zOxrh*w=!`FmVoSHo^zu_fz0x*LooP@S>E;>TBykpVzMYA1v;j4by5z_7o+4uxP08e zk>xRw)Kyy};t>%fSpZWqqfal(BzXSF1)p;6K4kHLX?01G0m2X)UAu!rRq`%LK5jto z&@@g}cpX?3+8sumhMFIuri-t8yI~6Gx7~F zdN4JM>CiTZ{atk(C`@wGj}~XYe(*LV{RmvQd95XN_rf(SyGFz9>kr@!NRW`&y&l}$ zJe5d=$nfx30uoQdNUzqi(6z=uD>lTur57w9yZ5Giblnr(ai4qdU25pG@2+}J+$cpw z{@51PuqE`n{mx+2ublBh_XsF@OqBWP=1S_NyUU+EJ+n^fOipQBUjD4_FS~00O!V*u z8?ACHdcEH}p#SbU1S{tYlSQhBbhHT>rAIJfiwoIW)dQT6#u@rkE=H< zGd&9V2SKC3s4zcwuwNEBs4(^R>zKA1ey_l24KzvpSs+w{Tz`{iE;eOu!xE6q>I23i zB_apVZ(JC`Tp^c!=f#ejrYAyy6q{HoUO|;=B~jBs3bQI<95X3h1?hHSbak1t&F}*9 z9|9cudsLlGVmzAQ*!RE9CnBze>oNtMzl=;1C6NdBOKy=2kIr3rK*rQ`s+tw`={Hi1 zI*||$qJ%O*TGcK-A4x@dYztNdNZSD1k>&nDG?m%7%pQM=K3Rliu`Ku74e!!*)&ht5 zbOmmc1NUIoA`@=imb=7R-+dK*S7-zsl_O`{snI?RpIOdu`xj0-5XD;T2d$!V&CpUx zlvXH9!TDCDXOGUqH?b|emcKwDM$x}*feVKPj3J$^gCx?9r_zKhlD$8Wxys1uS-)Ms z-4CZD8r*upn%4U{@J{v95dBlq_nnkQU#A373jDM7%SMu%c)-bx!7j9J(cIjRHNsSj z*G-j98^c*_j7RIA8#3`cjOK?-Y{Fh44K|QzaoB$8r%{DkRp@I&>EJ^Ph=f20Y-8gF z#V>Kap{UN$q36MO&&lQ+?lyPqaUWvr2@Qhv8X|PHd^kGG?KUc?Z|#WnBYq!fTv0_X z5eZ?$)*Y|GKC0&~xe&}u;@?B+gIDM*xWGKLOLbmN-AA*Fqwpty;;9~g` zxCp~jF&=I}(hv9IiwByXA!Arv$ITb5U!ST!T(x^6SoZ?nRtps6m>>MdtvjM2N9=Cx2{MD@8 z+4DAjJMZnRt*Edsx{Uit4<^Svdhlu?)O;d9D-n=h%mWiUql(g5(cCdko}X)?AEAsI zTtKO*CE3wPz=kyB6z8odt-m^{KdnuQCpp05JmvJ|BqpVmiNDPk!U_?zEN}BI&D|GQ zSd!|CXVD@hV#*1CO$tWKvqhE#nVtdKS4V_cuSlvT9Ajg0UG8HrjUM7(DB*Nrmf$XS zc2$cT2LCn&tZz_B&O2;`JvySzxScffBl>a+{N8E%vHlm^vGxjkS(O2uB^l$uc9=^!MGyD1*h8KW{m??J;JKg}c8@H1~R;wH1>C zQ?(nx^If>Ll$qtv^ z*LEeB1e;U#-J1A%*6n_xinun!m?V>NkXcw&9CpB)7UplnGmxCe;|t@ldM^P`gw4xjWG;}BM005SH5;JKH0ATYt0v%G-Lj&;kF|3 zbzl8rhd`+H^c4@*LR`Gr`#;|LJHI_@ynD&7w0ObV_jQDY8ThI>1)N4-zbos&^mpvR z+tivk?5A;J=^g1uDDQ}&Kn#L;0nmB9-dU}wC2~JbHWmqJ8@3;I?j}%b|F#~dUelYv z-qKh1{LAA%P`PBD_zO|h@mCO3s>16}>Sfop9$lRxMl_?YX?Ek>=i@})Vtt~{9nFY- z4Gavd&-uNk)ME%x4$H9q(C(@kRIdqid%X~AN0HQ5RY71t8~I@+bk-UTNd6*=S@cFo-n@%c zmEY0l#A2?S(aKcv*mYC)gCk5e?4(aYIH_8w}M8IAN)X#bHWpQ+IDwOZ*k{e!vt**yLze8_SV&fLQRqD+0|zZQN5uGFi+-<7;uV*06kjVIvkSKhfMfrCX=S32 zFhrVtI0+U1hdT;w2AD3kN$_px1}?tb5j!Zrs;1Z;*kr7@URaii&&1PLYF;q6bx7er>pcTEquWX-p`43zgxoi_XSQRi+ZmGChj3`z`>YKAkJBMqCR;bDGRyrei zehFY`4nb9SQYW}um%|podII#xgSol4?9*ESq8cXnJ)gsHULmN**p*d0m%>8Mk`;)w zB%vvT*YD^o`Kd#iFF^`L*bZuXfll*b800Anud=EZYOk2_sv6A>4le;39m9lZWJTuq z@~JYBy0_hbbkmtt`wMbDGdPs8PCo<>@WX~piU_d56xw_{@GN2j)(bS;_I)9-SDEZO zh>n94$%c1>M;1ulAv2V`<56>q6K-d_`4w|0l`cdUXnh3G2g8lezRh7U1j)l$q9Ltf zhpl`+NFir79!kz_-q_=y6304+w`rppUXX;Quej_oL{C?gt7dVxN4)0O#Dbq2>6zPq&Lbiamz+jtMSKvPGR!ja06yuP?NDJb}=i> z&-a%SGtOlRDCM#THXj}e`op``oDx#MaH@Cv(z088lvs(|KP0&cVIp;@>&c&rbkn>k z5Jg%fg#5b+H%6L_K|b2BNm5+)l~=VmkFGwUA^l< zU0|BHPbhU(iA-RR2uokbx>vVG4<6jQZ_ECxxocOCe0yv5$lA`YJ?%!$`wcs*pLP<5 zVc-hM-w(B{-+D)ic*Znfa+p(IE^*Y4UOWC^ZsOS)f53#{D)Gc|+PZZNyyy%Zd;NTu zu@f9&VyUQ>7h3j$B!0;VmiKqJx_i%(H=?4LE|U2u{dSaFlJiCo!$vcZ-c@2JWMmwr z%xTNMPME^ef5nEXrBh)Od1MC*WzUrbXQ-*8F}#vr99?65sI+Y{S4NuRsi~qVru9M8 zR7c~pb9r<=LOy;ErLi2)R6f7gFQC_SsHsky6Ulj3oLlX%bM>rtDd4y374$KQC}b%T zb<$z+n@_*S&J+ONL)d3OKVbXDWY9Z&XQ;;BJTQb);m8bjZ?I82X|F+IX+SrdiM`Mu zuMdaNVXE$`88ydiPhu2eTOIo*c~4}rPlIeuJEVIBOrAB34`Ets-mdJuGx+PoyGbcp zVu59Z0js6FR+RD;{fOvFW){lRXL8;u6rSJ*F4am(+B*Jf;xzlc`Qhw5J5au%2+Qcx zhcdM|$#t2>6UhQ^*cSL?RkyJh|UQ%_MHm zt4-Y6) zQJ1WQHe;|=PC-hFCL~}Sl%C8&8EHF#HLkeQqaUSg7yj^}2^)`k^I+dnZJnKamo2ji zq+fJszj+W3$7}@sv%@`{g++BN zJC6;rC(4motNOkVLW0|&!}m;OKR4TAOU7~5Y}kL+Lz7sj;o+q6E^>62&)p9!Pa=yrL*ayYBs5^@^+)uF;0vRgrpkqZtpG9*qndv8@XLpT2B(Zm6jp zTkGL{O%?{E%*udoP#|lqZWQ=16U1JnVNVy7tHWw1W~gd>EPcxIc^H2BSrk; zkVGC&N*Qs;=@(kU6rQaEYF}K4)_x6wl2vvq6e9hZiC9dNBEC}-U6 zUiD=6Y`>^QIG>qnR=vxJB@$}SO1fX-Kxf@+v?;j~CueXYAtx9(Qwr3=oGVGZ)0J-zNlfL4a@FMU)OZ|8Vs;K<e)92;Ymg;eQ54}6c`C)>rtlNY$z43Y6Mm8Mne`qtN-uZ+7+)HsmYuvxhbt9Ql z97wEcv{})Yn7Ru)S0ULk!jzTW5^tb(|KK%-9Cme4SkPk4e;iL)k1Saz;RPdemcP8< zwA#-WCb`ATy}Kk;E|kHbpRJ`)eJ|ojwO@Qz?>jWe$2K-V=QWEWGk?U4Cl_%;tr%;p zSf=}c3q+KZ*&+nny0L#i(LiK3J!1YcqlN2|WoF!0nIXo!f&iE=55CWrn_tesu<8WA zjpabH|JUaycAO@$)u6k$1c2X zQTs_!WJAzGt7Gi2_}O+PE9gWW2}7b$(gahe&IS9@d2lWI1k&5Rye;RlP)tj%p1Z*# z*_3`+whY_U%Oj93jyfkE(|ma)UKBX<{B~8P@$_we{`|S+jlCW~@sfs_;O)H5S0w)9 z9i74}jMEFS$j>9B<`&?NI_`B=IU$1TER>(%c!t7r@e;3VW+6W8mn#i_NNY3FX0~E*s~Xa&kt@ykI#+Uw zQdGELxFgt$GSs?pSC6t*4J7cCmdpU4BN$DPIEe$#zRqTfSYagWB=yIz#AAunML7nm)&tW%dT!zYf1cct;i zgK`kVwY}3_Ca%rc`0=6?4viih&CgmhMA&$e+_tawm7--CY_;+c!!7tk`Z)aTQn;j41NIW^oQUL|a@j(}^{>26hedF3R zzLIPt*I+O_Go5bho*R#9fu~L{#*D-^ilV{8;1FY`>iNmh4HyUt$#RWp<|>$)fV+U4 z8L*ys0|utfxk7bQ@DnwUl4$vkz}cP~pKqOp0obEe&77Z~!4pd|!tU|u!NY7j={Q(~ zwHbkQxw{>2ql<*@n8fgDGb9;<@5S+E7tuWtH?XzcaFz&IaZR`3B zsVdrj^5y;*ZqxNb*VelfA%bx4Y4U!*x6&(5bmBaj|s5x(1s?7MiKt_f@*zwLre zs4bhe3S$#_AbpUp45&sgY*F~C4jp5jv8WRN+i=qfD%Gh1sRqyjIXW$%Bg^rwmu?6!WODo~DfjF6Dz~B&Pe)5ro2*P-jn7x#B26FJc zQtUYLBcD*vw(mWJ)`kCYqO!}3%{-DD*B?`FAJ>g~ykJvGHjm`qoa#qs$g5$+df?Uu ze1{!+ekmugJC|A%4vq%Tm(=wI{*fEm8aR8-2baL`uV7m9Ii6s9rA?0GWxqa-OS7q6 zkY+TC&k@+$xt?UtH{`Mi3^-i%KJzAZ#vnB^C~4)Mi_kk*kcl>{Aj4=yFng`NiX zZ}7O3a#5=ay(A6D`Y=E99>z1rL#72cKdGDV> zMFR(x-5IoE6Q`G7(J}_ga{jt8u(9eu<{pmJ*w?|;$$gHi`1yA5*?k)I1k2Jh%B9hB zD7#BgoLK_tVt+Us3pa^UuDVO{Xv98i+BIW|8t(dF%4!;0S!Xo&r$@!#*xZ4+xaNp%j5xRW-^5sx%rxkVN`KBRS@oV7EBN5Du?-)t>@-y_u8#Da}S zAU28;Q|;l1_I}>|qg@t*xZEwNVrmH~Fg*)Aa8KftMw*u9wP)Xe2+NLf-2$iAhRmt1 z2!wcdmQA9VvYWeRN5zP3Z?m$zy!>WLryjTCM>P5J;5Md-933B$dx%4X)%-Okd`i$- zRKTM_t)pU$d6XoOJXnqCVC?Cmj5@W%~Dt%NH1$!q7(J_P(9wu!eYaf+>(FP zw;k*^iZVa-@M4cnlzCyMDGP%bSLWO(veP%?2%+;k~@oETj1u3NE>T}!;r1^Gvw z>euEAhfq_aw`|Q^jbb6yfH6NwL7VY*K0edl?eFa#py05=Te#f8b#k`WP7-^<)+9DG}SVY{z=rijnjq8k|ax{mdo|i?80EC?>R_76=ba?LbUanl$`-g z94RwI=j5jv)IRk+9 zLm3bM>Gv={n5Tq>wcAp3OzC5T^^Y!|1o70!bbAD=>9r=Ogk zu)=*E&d4ha@%v~{;+9h~s-x`jd`|dwU9-L*Lr&x{&+^4*uzzD^c_#lHIxK0kwEq&Hx;XcU*`m{#QKul5JvszjWGw0y8uy9s>zMqy-1mdezI|7}uNDZWJPEnB)~~KsdPP-) z9)$%BltAVymI={7y7o2m>kS4`${WA3ul4vxGpkc^HH?L}0u0Ax0fYgqzJ=pcvnlGN zJy*`6Irt^0z{%rcF)LgdXr+0o?i4_SrXjl(ui0xY=Si9cElm;)u4wYAmAd^db^30cnr>dfYD@7w)hH--{%v%h0RB==?^(p#*Rsh zlGi}xC^|;MIwD%_HoiCIYhFDfD|+#TErahA)j2Vbf4qc8lC$z8!&V24>t*|*d&bzE z{yJPdE`t2hOsFQKA${U_d3Mo(p6G)%oc24&!fv4di-l$5dA&_uVURc`9yosNSqOtg z#KX5<>U15PA(~xyBePv-dALYwZVYaaGv_1gtGBE@)&%QYA4i!Q#oe;=_JwBbcFl0S zz)39_h(r_04@Ro#k*t8?%LB*oS1rbr&-~mtaR%0ym@$0(jn*ZDYQPcqus54$T_!j> zq+`}1-WoqZU*MN%#R=v6L8UL6i6v!*BbT6pJrzG~rzP>kU_RRl^#hnFK7sukDOs5L zQbya(V?I{UcX5&bmuPFukE+A!W;?R&J_=!#TTfl|6OSXtc7J&RRQezKk)SgrMUbz@ zC#6}I1NjPw*0I*bZKP^T3j-b?8O&}7FsOf-HIOfn?2%XdJ2Gwdm>BIFXkS8*d}1^9 zF@ssxzQoP2cXB-Qw4>#!+$Tl~Y0=YdtirF`ShabQHOc*$<3y8$X;#6F7s_wg!OCEJ z7e2nl6?2<{jVlATvWr;U`FrPspyI|XjS+ung@{9YwM8xQss@g21U`KR=KDTfL4r9C z+!AlD6w_H{PzBM-7^zQ7nZ8yZ@3JshD0O<%zB3zl-{EKfbX$F6+Aeg3)vE{PLenFn z1%?AYbM?$QbsRn7z1d>wPZ{bRAU9-Ak^pFy1)b)TgnR*xoXz@|-07but=US`e0T@^sW1~< zYihzCZ_XmYOAcB9TmR~y4JvMBDdZtzU2x{JyDvRiANNyCpIaz!ze}_vQWx%07aWfV zkxNf(sZjWWZFreywP~cvi)b?*pHcY8%qwZKK~CSq3h%ZDyA3DG5dl-(yi+r7+&((y zJvBRuYih>iOuj@m>fXL>ms|^PJ|FFN96v3$VxV>_OGfmkiO^H(t>0D+7%a4mUo^zQ zK7AlIX)?Z}g)4GUOzg!9nB1h7Sz`sfD>~y-umWeX0vn<82P?bWoOgzTk27;%P$2-P zI)LO6Yo4_3P{%IbE?YW58l0wo-r!seqz|&MIWq3od!`5sI;dzC^}44aF3R(q``Vzd zF5DW{hjA7SSIy*N>)Q#O^y=^*(f1v`rdgGAn0~{hESs=pwXcj;lzB))Uk^&v8&~mh z{~e-|wa$xok2t9|!am|Y=gaTkoWjQQ{gS`+9{ixqz3)~^xbu9uSZLQ>?y#4k$atEw zQXm686`9=n+*hfk5yct+5t^5QwS%xOm4`HLwvzv}aZ{NqS?-+`=lP_1`R61y9_G2p zYTJ{Tq|x4U5wUeVz->c>L}dWv2Zxda?|bX@E57e-*P~cIjC=qRQ$z#+$rwcVJlnKC zJG~@EcXGIM9A~}Q|4-X$ zk_oI8;c^q$i(c+`yTyU@NsmR%$@szMlF6oUJi}KrXYlmr`+V6WLGZ!4U*m&qx>4K) zdexV2e%>Zi74-1Et}ShGq<;r74uRt9veHSQHjnL}UVbf#XYqKZ#T>I36i+ADkq0u8 zy9E%V?7ArP^DWYSx=~Sh3@-q-$v8CMQlBUJW!;agK9tfq2_u&r@q_(lxF6ab0GB1^ z>TTUB%|c1>^!p6-e))~0Bspyzt0e=QDp2-a%0D5CmdndGR$FI-$8s$;r^muCZ*?!< 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%0D6M|VWevO47X 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

IGGAt#TbI%;Wkf@ad^2Ou839!&d)`) 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 literal 61054 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 Date: Fri, 28 Mar 2025 19:50:40 +0000 Subject: [PATCH 164/260] Added provider support to InferencePool helm chart (#595) * Added provider support to InferencePool helm chart * Removed the redundant pool name flag --- config/charts/inferencepool/README.md | 28 ++++----- .../charts/inferencepool/templates/NOTES.txt | 2 +- .../inferencepool/templates/_helpers.tpl | 4 +- .../inferencepool/templates/_validations.tpl | 5 -- .../templates/epp-deployment.yaml | 2 +- .../charts/inferencepool/templates/gke.yaml | 59 +++++++++++++++++++ .../templates/inferencepool.yaml | 2 +- config/charts/inferencepool/values.yaml | 7 ++- 8 files changed, 81 insertions(+), 28 deletions(-) create mode 100644 config/charts/inferencepool/templates/gke.yaml diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md index 30087527..681fc783 100644 --- a/config/charts/inferencepool/README.md +++ b/config/charts/inferencepool/README.md @@ -9,20 +9,14 @@ To install an InferencePool named `vllm-llama3-8b-instruct` that selects from e ```txt $ helm install vllm-llama3-8b-instruct ./config/charts/inferencepool \ - --set inferencePool.name=vllm-llama3-8b-instruct \ --set inferencePool.modelServers.matchLabels.app=vllm-llama3-8b-instruct \ - --set inferencePool.targetPortNumber=8000 ``` -where `inferencePool.targetPortNumber` is the pod that vllm backends served on and `inferencePool.modelServers.matchLabels` is the selector to match the vllm backends. - To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: ```txt $ helm install vllm-llama3-8b-instruct \ - --set inferencePool.name=vllm-llama3-8b-instruct \ --set inferencePool.modelServers.matchLabels.app=vllm-llama3-8b-instruct \ - --set inferencePool.targetPortNumber=8000 \ oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0 ``` @@ -38,17 +32,17 @@ $ helm uninstall pool-1 The following table list the configurable parameters of the chart. -| **Parameter Name** | **Description** | -|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------| -| `inferencePool.name` | Name for the InferencePool, and inference extension will be named as `${inferencePool.name}-epp`. | -| `inferencePool.targetPortNumber` | Target port number for the vllm backends, will be used to scrape metrics by the inference extension. | -| `inferencePool.modelServers.matchLabels` | Label selector to match vllm backends managed by the inference pool. | -| `inferenceExtension.replicas` | Number of replicas for the inference extension service. Defaults to `1`. | -| `inferenceExtension.image.name` | Name of the container image used for the inference extension. | -| `inferenceExtension.image.hub` | Registry URL where the inference extension image is hosted. | -| `inferenceExtension.image.tag` | Image tag of the inference extension. | -| `inferenceExtension.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | -| `inferenceExtension.extProcPort` | Port where the inference extension service is served for external processing. Defaults to `9002`. | +| **Parameter Name** | **Description** | +|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| `inferencePool.name` | Name for the InferencePool, and endpoint picker deployment and service will be named as `{.Release.name}-epp`. | +| `inferencePool.targetPortNumber` | Target port number for the vllm backends, will be used to scrape metrics by the inference extension. Defaults to 8000. | +| `inferencePool.modelServers.matchLabels` | Label selector to match vllm backends managed by the inference pool. | +| `inferenceExtension.replicas` | Number of replicas for the endpoint picker extension service. Defaults to `1`. | +| `inferenceExtension.image.name` | Name of the container image used for the endpoint picker. | +| `inferenceExtension.image.hub` | Registry URL where the endpoint picker image is hosted. | +| `inferenceExtension.image.tag` | Image tag of the endpoint picker. | +| `inferenceExtension.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | +| `inferenceExtension.extProcPort` | Port where the endpoint picker service is served for external processing. Defaults to `9002`. | ## Notes diff --git a/config/charts/inferencepool/templates/NOTES.txt b/config/charts/inferencepool/templates/NOTES.txt index 3d822165..22e5c0e1 100644 --- a/config/charts/inferencepool/templates/NOTES.txt +++ b/config/charts/inferencepool/templates/NOTES.txt @@ -1 +1 @@ -InferencePool {{ .Values.inferencePool.name }} deployed. +InferencePool {{ .Release.Name }} deployed. diff --git a/config/charts/inferencepool/templates/_helpers.tpl b/config/charts/inferencepool/templates/_helpers.tpl index bb15f9e4..e011bb7c 100644 --- a/config/charts/inferencepool/templates/_helpers.tpl +++ b/config/charts/inferencepool/templates/_helpers.tpl @@ -12,7 +12,7 @@ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} Inference extension name */}} {{- define "gateway-api-inference-extension.name" -}} -{{- $base := .Values.inferencePool.name | default "default-pool" | lower | trim | trunc 40 -}} +{{- $base := .Release.Name | default "default-pool" | lower | trim | trunc 40 -}} {{ $base }}-epp {{- end -}} @@ -20,5 +20,5 @@ Inference extension name Selector labels */}} {{- define "gateway-api-inference-extension.selectorLabels" -}} -app: {{ include "gateway-api-inference-extension.name" . }} +inferencepool: {{ include "gateway-api-inference-extension.name" . }} {{- end -}} diff --git a/config/charts/inferencepool/templates/_validations.tpl b/config/charts/inferencepool/templates/_validations.tpl index 55ed80c8..65c743b6 100644 --- a/config/charts/inferencepool/templates/_validations.tpl +++ b/config/charts/inferencepool/templates/_validations.tpl @@ -2,11 +2,6 @@ common validations */}} {{- define "gateway-api-inference-extension.validations.inferencepool.common" -}} -{{- if not $.Values.inferencePool.name }} -{{- fail "missing .Values.inferencePool.name" }} -{{- end }} - - {{- if or (empty $.Values.inferencePool.modelServers) (not $.Values.inferencePool.modelServers.matchLabels) }} {{- fail ".Values.inferencePool.modelServers.matchLabels is required" }} {{- end }} diff --git a/config/charts/inferencepool/templates/epp-deployment.yaml b/config/charts/inferencepool/templates/epp-deployment.yaml index ded9cb12..9faace73 100644 --- a/config/charts/inferencepool/templates/epp-deployment.yaml +++ b/config/charts/inferencepool/templates/epp-deployment.yaml @@ -22,7 +22,7 @@ spec: imagePullPolicy: {{ .Values.inferenceExtension.image.pullPolicy | default "Always" }} args: - -poolName - - {{ .Values.inferencePool.name }} + - {{ .Release.Name }} - -poolNamespace - {{ .Release.Namespace }} - -v diff --git a/config/charts/inferencepool/templates/gke.yaml b/config/charts/inferencepool/templates/gke.yaml new file mode 100644 index 00000000..86e8c4ff --- /dev/null +++ b/config/charts/inferencepool/templates/gke.yaml @@ -0,0 +1,59 @@ +{{- if eq .Values.provider.name "gke" }} +--- +kind: HealthCheckPolicy +apiVersion: networking.gke.io/v1 +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + targetRef: + group: "inference.networking.x-k8s.io" + kind: InferencePool + name: {{ .Release.Name }} + default: + config: + type: HTTP + httpHealthCheck: + requestPath: /health + port: {{ .Values.inferencePool.targetPortNumber }} +--- +apiVersion: networking.gke.io/v1 +kind: GCPBackendPolicy +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + targetRef: + group: "inference.networking.x-k8s.io" + kind: InferencePool + name: {{ .Release.Name }} + default: + timeoutSec: 300 # 5-minute timeout (adjust as needed) +--- +apiVersion: monitoring.googleapis.com/v1 +kind: ClusterPodMonitoring +metadata: + name: {{ .Release.Namespace }}-{{ .Release.Name }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + endpoints: + - port: metrics + scheme: http + interval: 5s + path: /metrics + authorization: + type: Bearer + credentials: + secret: + name: {{ .Values.gke.monitoringSecret }} + key: token + namespace: {{ .Release.Namespace }} + selector: + matchLabels: + {{- include "gateway-api-inference-extension.labels" . | nindent 8 }} +{{- end }} diff --git a/config/charts/inferencepool/templates/inferencepool.yaml b/config/charts/inferencepool/templates/inferencepool.yaml index 2b79f399..4b279cbd 100644 --- a/config/charts/inferencepool/templates/inferencepool.yaml +++ b/config/charts/inferencepool/templates/inferencepool.yaml @@ -2,7 +2,7 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: - name: {{ .Values.inferencePool.name }} + name: {{ .Release.Name }} namespace: {{ .Release.Namespace }} labels: {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml index 7b0c8f96..45dd11a1 100644 --- a/config/charts/inferencepool/values.yaml +++ b/config/charts/inferencepool/values.yaml @@ -8,8 +8,13 @@ inferenceExtension: extProcPort: 9002 inferencePool: - # name: pool-1 # REQUIRED targetPortNumber: 8000 # modelServers: # REQUIRED # matchLabels: # app: vllm-llama3-8b-instruct + +provider: + name: none + +gke: + monitoringSecret: inference-gateway-sa-metrics-reader-secret From 5df69682b2623c98d9e8a5eaf9e53cccfb97f0d1 Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Fri, 28 Mar 2025 14:34:44 -0700 Subject: [PATCH 165/260] make dynamic lora sidecar health check parameters configurable and force reconcile (#605) * update benchmarking guide with latest results with vllm v1 * update graph * make dynamic lora sidecar health check parameters configurable and forrce reconcile * update screenshots * make the health and refresh params in sidecar cmd line argument --- tools/dynamic-lora-sidecar/Dockerfile | 4 +- tools/dynamic-lora-sidecar/README.md | 69 ++++++++++++--- .../screenshots/configmap-change.png | Bin 129261 -> 0 bytes .../screenshots/lora-syncer-logs.png | Bin 0 -> 531977 bytes .../screenshots/lora-syncer-sidecar.png | Bin 180452 -> 0 bytes tools/dynamic-lora-sidecar/sidecar/sidecar.py | 79 ++++++++++++++---- .../sidecar/test_sidecar.py | 70 ++++++++++++---- 7 files changed, 174 insertions(+), 48 deletions(-) delete mode 100644 tools/dynamic-lora-sidecar/screenshots/configmap-change.png create mode 100644 tools/dynamic-lora-sidecar/screenshots/lora-syncer-logs.png delete mode 100644 tools/dynamic-lora-sidecar/screenshots/lora-syncer-sidecar.png diff --git a/tools/dynamic-lora-sidecar/Dockerfile b/tools/dynamic-lora-sidecar/Dockerfile index 4f6c743e..4faf360c 100644 --- a/tools/dynamic-lora-sidecar/Dockerfile +++ b/tools/dynamic-lora-sidecar/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.9-slim-buster AS test WORKDIR /dynamic-lora-reconciler-test COPY requirements.txt . -COPY sidecar/* . +COPY sidecar/* ./ RUN pip install -r requirements.txt RUN python -m unittest discover || exit 1 @@ -18,6 +18,6 @@ RUN pip install --upgrade pip COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY sidecar/* . +COPY sidecar/* ./ CMD ["python", "sidecar.py"] \ No newline at end of file diff --git a/tools/dynamic-lora-sidecar/README.md b/tools/dynamic-lora-sidecar/README.md index be05f9e9..f14dbfc7 100644 --- a/tools/dynamic-lora-sidecar/README.md +++ b/tools/dynamic-lora-sidecar/README.md @@ -29,21 +29,34 @@ The sidecar uses the vLLM server's API to load or unload adapters based on the c ## Usage + 1. **Build the Docker Image:** ```bash docker build -t . + ``` + 2. **Create a configmap:** - ```bash - kubectl create configmap name-of-your-configmap --from-file=your-file.yaml + ```bash + kubectl create configmap name-of-your-configmap --from-file=your-file.yaml + ``` + 3. **Mount the configmap and configure sidecar in your pod** - ```yaml - volumeMounts: # DO NOT USE subPath - - name: config-volume - mountPath: /config - ``` - Do not use subPath, since configmap updates are not reflected in the file + ```yaml + volumeMounts: # DO NOT USE subPath + - name: config-volume + mountPath: /config + ``` + Do not use subPath, since configmap updates are not reflected in the file -[deployment]: deployment.yaml it uses [sidecar](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/)(`initContainer` with `restartPolicy` set to `always`) which is beta feature enabled by default since k8s version 1.29. They need to be enabled in 1.28 and prior to 1.28 sidecar are not officially supported. +## Command Line Arguments + +The sidecar supports the following command-line arguments: + +- `--health-check-timeout`: Maximum time in seconds to wait for the vLLM server health check (default: 300) +- `--health-check-interval`: Interval in seconds between health check attempts (default: 2) +- `--reconcile-trigger`: Time in seconds between forced reconciliation runs (default: 5) +- `--config`: Path to the config map file (default: value from DYNAMIC_LORA_ROLLOUT_CONFIG env var or "/config/configmap.yaml") +- `--config-validation`: Enable config validation (default: True) ## Configuration Fields - `vLLMLoRAConfig`[**required**] base key @@ -61,11 +74,41 @@ The sidecar uses the vLLM server's API to load or unload adapters based on the c - `source`[**required**] path (remote or local) to lora adapter - `base-model`[*optional*] Base model for lora adapter - - +## Example Deployment + +The [deployment.yaml](deployment.yaml) file shows an example of deploying the sidecar with custom parameters: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dynamic-lora-reconciler +spec: + replicas: 1 + selector: + matchLabels: + app: dynamic-lora-reconciler + template: + metadata: + labels: + app: dynamic-lora-reconciler + spec: + containers: + - name: reconciler + image: your-image:tag + command: ["python", "sidecar.py", "--health-check-timeout", "600", "--health-check-interval", "5", "--reconcile-trigger", "10"] #optional if overriding default values + volumeMounts: + - name: config-volume + mountPath: /config + volumes: + - name: config-volume + configMap: + name: name-of-your-configmap +``` + +Note: This uses [sidecar](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/)(`initContainer` with `restartPolicy` set to `always`) which is beta feature enabled by default since k8s version 1.29. They need to be enabled in 1.28 and prior to 1.28 sidecar are not officially supported. ## Screenshots & Testing The sidecar was tested with the Deployment and ConfigMap specified in this repo. Here are screen grabs of the logs from the sidecar and vllm server. One can verify that the adapters were loaded by querying `v1/models` and looking at vllm logs. -![lora-adapter-syncer](screenshots/lora-syncer-sidecar.png) -![config map change](screenshots/configmap-change.png) +![lora-adapter-syncer](screenshots/lora-syncer-logs.png) ![vllm-logs](screenshots/vllm-logs.png) diff --git a/tools/dynamic-lora-sidecar/screenshots/configmap-change.png b/tools/dynamic-lora-sidecar/screenshots/configmap-change.png deleted file mode 100644 index e17f060b80097b09143c81a72b574de15e2616db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 129261 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

#*7tvHTn3SHj7wG*d*RhSerK+jh|i4^ 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 z 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⪙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 E3qD_}h5!Hn literal 0 HcmV?d00001 diff --git a/tools/dynamic-lora-sidecar/screenshots/lora-syncer-sidecar.png b/tools/dynamic-lora-sidecar/screenshots/lora-syncer-sidecar.png deleted file mode 100644 index c7b902539ce9d140b557ff548f5c3722a81ebb03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180452 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�hE#Lfsg($dvNYpXf9yLf9nx!<0=73fjYsY&VZz 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 diff --git a/tools/dynamic-lora-sidecar/sidecar/sidecar.py b/tools/dynamic-lora-sidecar/sidecar/sidecar.py index 02070f3f..00de99e3 100644 --- a/tools/dynamic-lora-sidecar/sidecar/sidecar.py +++ b/tools/dynamic-lora-sidecar/sidecar/sidecar.py @@ -1,6 +1,7 @@ import requests import yaml import time +import argparse from jsonschema import validate from watchfiles import awatch from dataclasses import dataclass @@ -30,18 +31,35 @@ def current_time_human() -> str: return now.strftime("%Y-%m-%d %H:%M:%S %Z%z") +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description='vLLM LoRA Adapter Reconciler') + parser.add_argument('--health-check-timeout', type=int, default=300, + help='Health check timeout in seconds (default: 300)') + parser.add_argument('--health-check-interval', type=int, default=2, + help='Health check interval in seconds (default: 2)') + parser.add_argument('--reconcile-trigger', type=int, default=5, + help='Reconciliation trigger interval in seconds (default: 5)') + parser.add_argument('--config', type=str, default=CONFIG_MAP_FILE, + help=f'Path to config map file (default: {CONFIG_MAP_FILE})') + parser.add_argument('--config-validation', action='store_true', default=True, + help='Enable config validation (default: True)') + return parser.parse_args() + + class FileChangeHandler(FileSystemEventHandler): """Custom event handler that handles file modifications.""" - def __init__(self, reconciler): + def __init__(self, reconciler, config_file): super().__init__() self.reconciler = reconciler + self.config_file = config_file def on_modified(self, event): logging.info("modified!") - logging.info(f"Config '{CONFIG_MAP_FILE}' modified!") + logging.info(f"Config '{self.config_file}' modified!") self.reconciler.reconcile() - logging.info(f"model server reconcile to Config '{CONFIG_MAP_FILE}' !") + logging.info(f"model server reconcile to Config '{self.config_file}' !") @dataclass @@ -65,10 +83,17 @@ class LoraReconciler: Reconciles adapters registered on vllm server with adapters listed in configmap in current state """ - def __init__(self, config_validation=True): - self.health_check_timeout = datetime.timedelta(seconds=300) - self.health_check_interval = datetime.timedelta(seconds=15) + def __init__(self, config_file, health_check_timeout, health_check_interval, + reconcile_trigger_seconds, config_validation=True): + self.config_file = config_file self.config_validation = config_validation + self.health_check_timeout = datetime.timedelta(seconds=health_check_timeout) + self.health_check_interval = datetime.timedelta(seconds=health_check_interval) + self.reconcile_trigger_seconds = reconcile_trigger_seconds + + logging.info(f"Settings initialized: health check timeout={health_check_timeout}s, " + f"interval={health_check_interval}s, " + f"reconcile trigger={self.reconcile_trigger_seconds}s") def validate_config(self, c) -> bool: try: @@ -77,14 +102,14 @@ def validate_config(self, c) -> bool: validate(instance=c, schema=schema) return True except Exception as e: - logging.error(f"Cannot load config {CONFIG_MAP_FILE} validation error: {e}") + logging.error(f"Cannot load config {self.config_file} validation error: {e}") return False @property def config(self): """Load configmap into memory""" try: - with open(CONFIG_MAP_FILE, "r") as f: + with open(self.config_file, "r") as f: c = yaml.safe_load(f) if self.config_validation and not self.validate_config(c): return {} @@ -93,7 +118,7 @@ def config(self): c = c.get("vLLMLoRAConfig", {}) return c except Exception as e: - logging.error(f"cannot load config {CONFIG_MAP_FILE} {e}") + logging.error(f"cannot load config {self.config_file} {e}") return {} @property @@ -215,8 +240,9 @@ def unload_adapter(self, adapter: LoraAdapter): def reconcile(self): """Reconciles model server with current version of configmap""" logging.info( - f"reconciling model server {self.model_server} with config stored at {CONFIG_MAP_FILE}" + f"reconciling model server {self.model_server} with config stored at {self.config_file}" ) + if not self.is_server_healthy: logging.error(f"vllm server at {self.model_server} not healthy") return @@ -240,21 +266,40 @@ def reconcile(self): async def main(): - reconciler_instance = LoraReconciler() - logging.info(f"Running initial reconcile for config map {CONFIG_MAP_FILE}") + args = parse_arguments() + + # Update CONFIG_MAP_FILE with argument value + config_file = args.config + + reconciler_instance = LoraReconciler( + config_file=config_file, + health_check_timeout=args.health_check_timeout, + health_check_interval=args.health_check_interval, + reconcile_trigger_seconds=args.reconcile_trigger, + config_validation=args.config_validation + ) + + logging.info(f"Running initial reconcile for config map {config_file}") reconciler_instance.reconcile() - event_handler = FileChangeHandler(reconciler_instance) + event_handler = FileChangeHandler(reconciler_instance, config_file) observer = Observer() observer.schedule( - event_handler, path=os.path.dirname(CONFIG_MAP_FILE), recursive=False + event_handler, path=os.path.dirname(config_file), recursive=False ) observer.start() try: - logging.info(f"Starting to watch {CONFIG_MAP_FILE} for changes...") + logging.info(f"Starting to watch {config_file} for changes and performing periodic reconciliation...") while True: - await asyncio.sleep(1) + # Get current trigger interval from reconciler + trigger_seconds = reconciler_instance.reconcile_trigger_seconds + logging.info(f"Waiting {trigger_seconds}s before next reconciliation...") + # Wait for configured trigger interval + await asyncio.sleep(trigger_seconds) + # Force trigger reconciliation + logging.info("Periodic reconciliation triggered") + reconciler_instance.reconcile() except KeyboardInterrupt: logging.info("Stopped by user.") observer.stop() @@ -262,4 +307,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file diff --git a/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py b/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py index 6f7e447f..59a60e6b 100644 --- a/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py +++ b/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py @@ -2,8 +2,10 @@ from unittest.mock import patch, Mock, mock_open, call import yaml import os -from sidecar import LoraReconciler, CONFIG_MAP_FILE, BASE_FIELD, LoraAdapter +import datetime +from sidecar import LoraReconciler, LoraAdapter, CONFIG_MAP_FILE, BASE_FIELD +# Update TEST_CONFIG_DATA to include the new configuration parameters TEST_CONFIG_DATA = { BASE_FIELD: { "host": "localhost", @@ -49,13 +51,14 @@ }, } } + EXIST_ADAPTERS = [ - LoraAdapter(a["id"], a["base-model"], a["source"]) + LoraAdapter(a["id"], a["source"], a["base-model"]) for a in TEST_CONFIG_DATA[BASE_FIELD]["ensureExist"]["models"] ] NOT_EXIST_ADAPTERS = [ - LoraAdapter(a["id"], a["base-model"], a["source"]) + LoraAdapter(a["id"], a["source"], a["base-model"]) for a in TEST_CONFIG_DATA[BASE_FIELD]["ensureNotExist"]["models"] ] RESPONSES = { @@ -101,7 +104,15 @@ def setUp(self, mock_get, mock_file): mock_response = getMockResponse() mock_response.json.return_value = RESPONSES["v1/models"] mock_get.return_value = mock_response - self.reconciler = LoraReconciler(False) + + # Create reconciler with command line argument values instead of config file values + self.reconciler = LoraReconciler( + config_file=CONFIG_MAP_FILE, + health_check_timeout=180, + health_check_interval=10, + reconcile_trigger_seconds=30, + config_validation=False + ) self.maxDiff = None @patch("sidecar.requests.get") @@ -167,20 +178,47 @@ def test_reconcile(self, mock_post, mock_get, mock_file): mock_get_response.json.return_value = RESPONSES["v1/models"] mock_get.return_value = mock_get_response mock_post.return_value = getMockResponse() - self.reconciler = LoraReconciler() - self.reconciler.reconcile() - # 1 adapter is in both exist and not exist list, only 2 are expected to be loaded - mock_load.assert_has_calls( - calls=[call(EXIST_ADAPTERS[0]), call(EXIST_ADAPTERS[2])] + # Create reconciler with command line argument values + self.reconciler = LoraReconciler( + config_file=CONFIG_MAP_FILE, + health_check_timeout=180, + health_check_interval=10, + reconcile_trigger_seconds=30, + config_validation=False ) - assert mock_load.call_count == 2 + self.reconciler.reconcile() - # 1 adapter is in both exist and not exist list, only 2 are expected to be unloaded - mock_unload.assert_has_calls( - calls=[call(NOT_EXIST_ADAPTERS[0]), call(NOT_EXIST_ADAPTERS[2])] - ) - assert mock_unload.call_count == 2 + # First check the call count + self.assertEqual(mock_load.call_count, 2, "Expected 2 load adapter calls") + self.assertEqual(mock_unload.call_count, 2, "Expected 2 unload adapter calls") + + # Check that the adapters with the correct IDs were loaded + loaded_ids = [call.args[0].id for call in mock_load.call_args_list] + self.assertIn("sql-lora-v1", loaded_ids, "sql-lora-v1 should have been loaded") + self.assertIn("already_exists", loaded_ids, "already_exists should have been loaded") + + # Check that the adapters with the correct IDs were unloaded + unloaded_ids = [call.args[0].id for call in mock_unload.call_args_list] + self.assertIn("sql-lora-v2", unloaded_ids, "sql-lora-v2 should have been unloaded") + self.assertIn("to_remove", unloaded_ids, "to_remove should have been unloaded") + + def test_health_check_settings(self): + """Test that health check settings are properly initialized from command line args""" + # Create reconciler with specific values + reconciler = LoraReconciler( + config_file=CONFIG_MAP_FILE, + health_check_timeout=240, + health_check_interval=15, + reconcile_trigger_seconds=45, + config_validation=False + ) + + # Check that values are properly set + self.assertEqual(reconciler.health_check_timeout, datetime.timedelta(seconds=240)) + self.assertEqual(reconciler.health_check_interval, datetime.timedelta(seconds=15)) + self.assertEqual(reconciler.reconcile_trigger_seconds, 45) + if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 8fdd1fad759a1888507e3cf9f2dd028d137c0a7e Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Fri, 28 Mar 2025 17:52:39 -0400 Subject: [PATCH 166/260] Fix verbosity flag in BBR helm chart (#606) --- config/charts/body-based-routing/templates/bbr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/charts/body-based-routing/templates/bbr.yaml b/config/charts/body-based-routing/templates/bbr.yaml index 4b888dcb..e740e06e 100644 --- a/config/charts/body-based-routing/templates/bbr.yaml +++ b/config/charts/body-based-routing/templates/bbr.yaml @@ -19,7 +19,7 @@ spec: imagePullPolicy: {{ .Values.bbr.image.pullPolicy | default "Always" }} args: - "-streaming" - - "v" + - "-v" - "3" ports: - containerPort: 9004 From 673999e9f555cb48f1177765291d1fe01b869f6b Mon Sep 17 00:00:00 2001 From: Nicole Xin Date: Fri, 28 Mar 2025 15:34:39 -0700 Subject: [PATCH 167/260] Adding getting started instructions for GKE, Istio, and Kgateway (#577) * Create resources.yaml for kgateway * Update getting started guide for KGateway * Replace Envoy Gateway user guide with GKE user guide * Create resources.yaml for GKE Gateway * Delete config/manifests/gateway/enable_patch_policy.yaml * Delete config/manifests/gateway/gateway.yaml * Delete config/manifests/gateway/patch_policy.yaml * Delete config/manifests/gateway/traffic_policy.yaml * Add http2 appProtocol to EPP service * Add user guide for Istio * Create resources.yaml for Istio * Fix GKE gateway name to match the user guide * Fix cleanup instructions to refer up-to-date YAMLs * Allow Istio gateway to use HTTPRoute from all namespaces * Update Kgateway port number to 80 * Update gateway port to 80 * Remove the sectionName from Kgateway HTTPRoute * Create common httproute YAML * Create healthcheck.yaml for GKE gateway * Separate gateway.yaml for GKE gateway * Separate gateway.yaml for Istio * Separate gateway.yaml for Kgateway * Update the user guide to use shared HTTPRoute YAML * Add EPP DestinationRule for Istio * Add instructions for bypassing TLS verification for Istio * Update CRDs to the latest v0.2.0 release Co-authored-by: Rob Scott * Update gateway to use the v1 API Co-authored-by: Rob Scott * Remove weight from HTTPRoute Co-authored-by: Rob Scott * Update gateway.yaml Remove allowed routes from GKE gateway YAML * Remove allowedRoutes from Istio gateway * Remove allowedRoutes from Kgateway * Update latest instructions for installing Istio and addressing some comments * Fix indentation for installing CRDs * Addressing code review comments * Fix indentation * Update Istio installation instructions * Fix indentation * Fix indentation * Add more spacing to the CPU based model instructions * Removing comments from kgateway * Add clarification on the EPP secureServing default value. Co-authored-by: Rob Scott * Add instructions for configuring timeout * Create httproute-with-timeout.yaml * Create gcp-backend-policy.yaml * Add cleanup for GCPBackendPolicy * Remove namespace from destination-rule.yaml * Rename inferencepool.yaml to inferencepool-resources.yaml * Rename inferencepool.yaml to inferencepool-resources.yaml * Rename inferencepool.yaml to inferencepool-resources.yaml --------- Co-authored-by: Rob Scott --- .../gateway/enable_patch_policy.yaml | 27 -- config/manifests/gateway/gateway.yaml | 50 ---- config/manifests/gateway/gke/gateway.yaml | 10 + .../gateway/gke/gcp-backend-policy.yaml | 11 + config/manifests/gateway/gke/healthcheck.yaml | 16 ++ .../gateway/httproute-with-timeout.yaml | 20 ++ config/manifests/gateway/httproute.yaml | 18 ++ .../gateway/istio/destination-rule.yaml | 10 + config/manifests/gateway/istio/gateway.yaml | 10 + .../manifests/gateway/kgateway/gateway.yaml | 10 + config/manifests/gateway/patch_policy.yaml | 123 --------- config/manifests/gateway/traffic_policy.yaml | 16 -- ...pool.yaml => inferencepool-resources.yaml} | 1 + site-src/guides/index.md | 242 +++++++++++++----- test/e2e/epp/e2e_suite_test.go | 2 +- 15 files changed, 292 insertions(+), 274 deletions(-) delete mode 100644 config/manifests/gateway/enable_patch_policy.yaml delete mode 100644 config/manifests/gateway/gateway.yaml create mode 100644 config/manifests/gateway/gke/gateway.yaml create mode 100644 config/manifests/gateway/gke/gcp-backend-policy.yaml create mode 100644 config/manifests/gateway/gke/healthcheck.yaml create mode 100644 config/manifests/gateway/httproute-with-timeout.yaml create mode 100644 config/manifests/gateway/httproute.yaml create mode 100644 config/manifests/gateway/istio/destination-rule.yaml create mode 100644 config/manifests/gateway/istio/gateway.yaml create mode 100644 config/manifests/gateway/kgateway/gateway.yaml delete mode 100644 config/manifests/gateway/patch_policy.yaml delete mode 100644 config/manifests/gateway/traffic_policy.yaml rename config/manifests/{inferencepool.yaml => inferencepool-resources.yaml} (99%) diff --git a/config/manifests/gateway/enable_patch_policy.yaml b/config/manifests/gateway/enable_patch_policy.yaml deleted file mode 100644 index 1e9818a1..00000000 --- a/config/manifests/gateway/enable_patch_policy.yaml +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: envoy-gateway-config - namespace: envoy-gateway-system -data: -# This manifest's main purpose is to set `enabledEnvoyPatchPolicy` to `true`. -# This only needs to be ran once on your cluster (unless you'd like to change anything. i.e. enabling the admin dash) -# Any field under `admin` is optional, and only for enabling the admin endpoints, for debugging. -# Admin Interface: https://www.envoyproxy.io/docs/envoy/latest/operations/admin -# PatchPolicy docs: https://gateway.envoyproxy.io/docs/tasks/extensibility/envoy-patch-policy/#enable-envoypatchpolicy - envoy-gateway.yaml: | - apiVersion: gateway.envoyproxy.io/v1alpha1 - kind: EnvoyGateway - provider: - type: Kubernetes - gateway: - controllerName: gateway.envoyproxy.io/gatewayclass-controller - extensionApis: - enableEnvoyPatchPolicy: true - enableBackend: true -# admin: -# enablePprof: true -# address: -# host: 127.0.0.1 -# port: 19000 -# enabledDumpConfig: true diff --git a/config/manifests/gateway/gateway.yaml b/config/manifests/gateway/gateway.yaml deleted file mode 100644 index 32f5d484..00000000 --- a/config/manifests/gateway/gateway.yaml +++ /dev/null @@ -1,50 +0,0 @@ - ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: - name: inference-gateway -spec: - gatewayClassName: inference-gateway - listeners: - - name: http - protocol: HTTP - port: 8080 - - name: llm-gw - protocol: HTTP - port: 8081 ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: GatewayClass -metadata: - name: inference-gateway -spec: - controllerName: gateway.envoyproxy.io/gatewayclass-controller ---- -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: Backend -metadata: - name: backend-dummy -spec: - endpoints: - - fqdn: - # Both these values are arbitrary and unused as the PatchPolicy redirects requests. - hostname: 'foo.bar.com' - port: 8080 ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: llm-route -spec: - parentRefs: - - name: inference-gateway - sectionName: llm-gw - rules: - - backendRefs: - - group: gateway.envoyproxy.io - kind: Backend - name: backend-dummy - timeouts: - request: "24h" - backendRequest: "24h" diff --git a/config/manifests/gateway/gke/gateway.yaml b/config/manifests/gateway/gke/gateway.yaml new file mode 100644 index 00000000..942cde5c --- /dev/null +++ b/config/manifests/gateway/gke/gateway.yaml @@ -0,0 +1,10 @@ +kind: Gateway +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: inference-gateway +spec: + gatewayClassName: gke-l7-regional-external-managed + listeners: + - name: http + port: 80 + protocol: HTTP diff --git a/config/manifests/gateway/gke/gcp-backend-policy.yaml b/config/manifests/gateway/gke/gcp-backend-policy.yaml new file mode 100644 index 00000000..519a5a93 --- /dev/null +++ b/config/manifests/gateway/gke/gcp-backend-policy.yaml @@ -0,0 +1,11 @@ +apiVersion: networking.gke.io/v1 +kind: GCPBackendPolicy +metadata: + name: inferencepool-backend-policy +spec: + targetRef: + group: "inference.networking.x-k8s.io" + kind: InferencePool + name: vllm-llama3-8b-instruct + default: + timeoutSec: 300 diff --git a/config/manifests/gateway/gke/healthcheck.yaml b/config/manifests/gateway/gke/healthcheck.yaml new file mode 100644 index 00000000..95f4f2d2 --- /dev/null +++ b/config/manifests/gateway/gke/healthcheck.yaml @@ -0,0 +1,16 @@ +kind: HealthCheckPolicy +apiVersion: networking.gke.io/v1 +metadata: + name: health-check-policy + namespace: default +spec: + targetRef: + group: "inference.networking.x-k8s.io" + kind: InferencePool + name: vllm-llama2-7b + default: + config: + type: HTTP + httpHealthCheck: + requestPath: /health + port: 8000 diff --git a/config/manifests/gateway/httproute-with-timeout.yaml b/config/manifests/gateway/httproute-with-timeout.yaml new file mode 100644 index 00000000..060f18c5 --- /dev/null +++ b/config/manifests/gateway/httproute-with-timeout.yaml @@ -0,0 +1,20 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: llm-route +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: inference-gateway + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: vllm-llama2-7b + matches: + - path: + type: PathPrefix + value: / + timeouts: + request: 300s diff --git a/config/manifests/gateway/httproute.yaml b/config/manifests/gateway/httproute.yaml new file mode 100644 index 00000000..5bd8bfb6 --- /dev/null +++ b/config/manifests/gateway/httproute.yaml @@ -0,0 +1,18 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: llm-route +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: inference-gateway + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: vllm-llama2-7b + matches: + - path: + type: PathPrefix + value: / diff --git a/config/manifests/gateway/istio/destination-rule.yaml b/config/manifests/gateway/istio/destination-rule.yaml new file mode 100644 index 00000000..f9cd0c3c --- /dev/null +++ b/config/manifests/gateway/istio/destination-rule.yaml @@ -0,0 +1,10 @@ +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: + name: epp-insecure-tls +spec: + host: vllm-llama2-7b-epp + trafficPolicy: + tls: + mode: SIMPLE + insecureSkipVerify: true diff --git a/config/manifests/gateway/istio/gateway.yaml b/config/manifests/gateway/istio/gateway.yaml new file mode 100644 index 00000000..dd762678 --- /dev/null +++ b/config/manifests/gateway/istio/gateway.yaml @@ -0,0 +1,10 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: inference-gateway +spec: + gatewayClassName: istio + listeners: + - name: http + port: 80 + protocol: HTTP diff --git a/config/manifests/gateway/kgateway/gateway.yaml b/config/manifests/gateway/kgateway/gateway.yaml new file mode 100644 index 00000000..7bcd08a6 --- /dev/null +++ b/config/manifests/gateway/kgateway/gateway.yaml @@ -0,0 +1,10 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: inference-gateway +spec: + gatewayClassName: kgateway + listeners: + - name: http + port: 80 + protocol: HTTP diff --git a/config/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml deleted file mode 100644 index 923ce22c..00000000 --- a/config/manifests/gateway/patch_policy.yaml +++ /dev/null @@ -1,123 +0,0 @@ -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: EnvoyPatchPolicy -metadata: - name: custom-response-patch-policy - namespace: default -spec: - targetRef: - group: gateway.networking.k8s.io - kind: Gateway - name: inference-gateway - type: JSONPatch - jsonPatches: - # Necessary to create a cluster of the type: ORIGINAL_DST to allow for - # direct pod scheduling. Which is heavily utilized in our scheduling. - # Specifically the field `original_dst_lb_config` allows us to enable - # `use_http_header` and `http_header_name`. - # Source: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto - - type: "type.googleapis.com/envoy.config.cluster.v3.Cluster" - name: original_destination_cluster - operation: - op: add - path: "" - value: - name: original_destination_cluster - type: ORIGINAL_DST - original_dst_lb_config: - use_http_header: true - http_header_name: "x-gateway-destination-endpoint" - connect_timeout: 1000s - lb_policy: CLUSTER_PROVIDED - dns_lookup_family: V4_ONLY - circuit_breakers: - thresholds: - - max_connections: 40000 - max_pending_requests: 40000 - max_requests: 40000 - - # This ensures that envoy accepts untrusted certificates. We tried to explicitly - # set TrustChainVerification to ACCEPT_UNSTRUSTED, but that actually didn't work - # and what worked is setting the common_tls_context to empty. - - type: "type.googleapis.com/envoy.config.cluster.v3.Cluster" - name: "envoyextensionpolicy/default/ext-proc-policy/extproc/0" - operation: - op: add - path: "/transport_socket" - value: - name: "envoy.transport_sockets.tls" - typed_config: - "@type": "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext" - common_tls_context: {} - - type: "type.googleapis.com/envoy.config.route.v3.RouteConfiguration" - name: default/inference-gateway/llm-gw - operation: - op: replace - path: "/virtual_hosts/0/routes/0/route/cluster" - value: original_destination_cluster -# Comment the below to disable full duplex streaming -# NOTE: As of https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/552 -# FULL_DUPLEX_STREAMED is the primary supported protocol for ext-proc. The buffered variant is no longer -# being actively developed, may be missing features/fixes, and will soon be removed. - - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - name: "default/inference-gateway/llm-gw" - operation: - op: add - path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_body_mode" - value: FULL_DUPLEX_STREAMED - - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - name: "default/inference-gateway/llm-gw" - operation: - op: add - path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/request_trailer_mode" - value: SEND - - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - name: "default/inference-gateway/llm-gw" - operation: - op: add - path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_body_mode" - value: FULL_DUPLEX_STREAMED - - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - name: "default/inference-gateway/llm-gw" - operation: - op: replace - path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_trailer_mode" - value: SEND - - type: "type.googleapis.com/envoy.config.listener.v3.Listener" - name: "default/inference-gateway/llm-gw" - operation: - op: replace - path: "/default_filter_chain/filters/0/typed_config/http_filters/0/typed_config/processing_mode/response_header_mode" - value: SEND ---- -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: EnvoyExtensionPolicy -metadata: - name: ext-proc-policy - namespace: default -spec: - extProc: - - backendRefs: - - group: "" - kind: Service - name: vllm-llama3-8b-instruct-epp - port: 9002 - processingMode: - allowModeOverride: true - request: - body: Buffered - response: - # The timeouts are likely not needed here. We can experiment with removing/tuning them slowly. - # The connection limits are more important and will cause the opaque: ext_proc_gRPC_error_14 error in Envoy GW if not configured correctly. - messageTimeout: 1000s - backendSettings: - circuitBreaker: - maxConnections: 40000 - maxPendingRequests: 40000 - maxParallelRequests: 40000 - timeout: - tcp: - connectTimeout: 24h - targetRef: - group: gateway.networking.k8s.io - kind: HTTPRoute - name: llm-route diff --git a/config/manifests/gateway/traffic_policy.yaml b/config/manifests/gateway/traffic_policy.yaml deleted file mode 100644 index e110f173..00000000 --- a/config/manifests/gateway/traffic_policy.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: BackendTrafficPolicy -metadata: - name: high-connection-route-policy -spec: - targetRefs: - - group: gateway.networking.k8s.io - kind: HTTPRoute - name: llm-route - circuitBreaker: - maxConnections: 40000 - maxPendingRequests: 40000 - maxParallelRequests: 40000 - timeout: - tcp: - connectTimeout: 24h \ No newline at end of file diff --git a/config/manifests/inferencepool.yaml b/config/manifests/inferencepool-resources.yaml similarity index 99% rename from config/manifests/inferencepool.yaml rename to config/manifests/inferencepool-resources.yaml index 639157c1..d0f36e83 100644 --- a/config/manifests/inferencepool.yaml +++ b/config/manifests/inferencepool-resources.yaml @@ -22,6 +22,7 @@ spec: - protocol: TCP port: 9002 targetPort: 9002 + appProtocol: http2 type: ClusterIP --- apiVersion: apps/v1 diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 99b78129..4548d5cd 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -1,9 +1,12 @@ # Getting started with Gateway API Inference Extension -This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get a first, single InferencePool up and running! +??? example "Experimental" + + This project is still in an alpha state and breaking changes may occur in the future. + +This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get an Inference Gateway up and running! ## **Prerequisites** - - Envoy Gateway [v1.3.0](https://gateway.envoyproxy.io/docs/install/install-yaml/#install-with-yaml) or higher - A cluster with: - Support for services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). @@ -39,11 +42,10 @@ This quickstart guide is intended for engineers familiar with k8s and model serv This setup is using the formal `vllm-cpu` image, which according to the documentation can run vLLM on x86 CPU platform. For this setup, we use approximately 9.5GB of memory and 12 CPUs for each replica. - While it is possible to deploy the model server with less resources, this is not recommended. - For example, in our tests, loading the model using 8GB of memory and 1 CPU was possible but took almost 3.5 minutes and inference requests took unreasonable time. - In general, there is a tradeoff between the memory and CPU we allocate to our pods and the performance. The more memory and CPU we allocate the better performance we can get. - After running multiple configurations of these values we decided in this sample to use 9.5GB of memory and 12 CPUs for each replica, which gives reasonable response times. You can increase those numbers and potentially may even get better response times. - For modifying the allocated resources, adjust the numbers in `./config/manifests/vllm/cpu-deployment.yaml` as needed. + + While it is possible to deploy the model server with less resources, this is not recommended. For example, in our tests, loading the model using 8GB of memory and 1 CPU was possible but took almost 3.5 minutes and inference requests took unreasonable time. In general, there is a tradeoff between the memory and CPU we allocate to our pods and the performance. The more memory and CPU we allocate the better performance we can get. + + After running multiple configurations of these values we decided in this sample to use 9.5GB of memory and 12 CPUs for each replica, which gives reasonable response times. You can increase those numbers and potentially may even get better response times. For modifying the allocated resources, adjust the numbers in [cpu-deployment.yaml](https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml) as needed. Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash @@ -52,68 +54,180 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Install the Inference Extension CRDs - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml - ``` - +=== "Latest Release" + + ```bash + VERSION=v0.2.0 + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/$VERSION/manifests.yaml + ``` + +=== "Dev Version" + + ```bash + kubectl apply -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd + ``` + ### Deploy InferenceModel Deploy the sample InferenceModel which is configured to load balance traffic between the `food-review-0` and `food-review-1` [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. + ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml ``` -### Update Envoy Gateway Config to enable Patch Policy** +### Deploy the InferencePool and Extension - Our custom LLM Gateway ext-proc is patched into the existing envoy gateway via `EnvoyPatchPolicy`. To enable this feature, we must extend the Envoy Gateway config map. To do this, simply run: ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/enable_patch_policy.yaml - kubectl rollout restart deployment envoy-gateway -n envoy-gateway-system + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool-resources.yaml ``` - Additionally, if you would like to enable the admin interface, you can uncomment the admin lines and run this again. -### Deploy Gateway +### Deploy Inference Gateway - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gateway.yaml - ``` - > **_NOTE:_** This file couples together the gateway infra and the HTTPRoute infra for a convenient, quick startup. Creating additional/different InferencePools on the same gateway will require an additional set of: `Backend`, `HTTPRoute`, the resources included in the `./config/manifests/gateway/ext-proc.yaml` file, and an additional `./config/manifests/gateway/patch_policy.yaml` file. ***Should you choose to experiment, familiarity with xDS and Envoy are very useful.*** + Choose one of the following options to deploy an Inference Gateway. - Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: - ```bash - $ kubectl get gateway inference-gateway - NAME CLASS ADDRESS PROGRAMMED AGE - inference-gateway inference-gateway True 22s - ``` -### Deploy the InferencePool and Extension +=== "GKE" - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool.yaml - ``` -### Deploy Envoy Gateway Custom Policies + 1. Enable the Gateway API and configure proxy-only subnets when necessary. See [Deploy Gateways](https://cloud.google.com/kubernetes-engine/docs/how-to/deploying-gateways) + for detailed instructions. - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/patch_policy.yaml - ``` - > **_NOTE:_** This is also per InferencePool, and will need to be configured to support the new pool should you wish to experiment further. - -### **OPTIONALLY**: Apply Traffic Policy + 1. Deploy Gateway and HealthCheckPolicy resources + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gateway.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/healthcheck.yaml + ``` + + Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: + ```bash + $ kubectl get gateway inference-gateway + NAME CLASS ADDRESS PROGRAMMED AGE + inference-gateway inference-gateway True 22s + ``` + +=== "Istio" + + Please note that this feature is currently in an experimental phase and is not intended for production use. + The implementation and user experience are subject to changes as we continue to iterate on this project. + + 1. Requirements + + - Gateway API [CRDs](https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api) installed. + + 1. Install Istio + + ``` + TAG=1.26-alpha.80c74f7f43482c226f4f4b10b4dda6261b67a71f + # on Linux + wget https://storage.googleapis.com/istio-build/dev/$TAG/istioctl-$TAG-linux-amd64.tar.gz + tar -xvf istioctl-$TAG-linux-amd64.tar.gz + # on macOS + wget https://storage.googleapis.com/istio-build/dev/$TAG/istioctl-$TAG-osx.tar.gz + tar -xvf istioctl-$TAG-osx.tar.gz + # on Windows + wget https://storage.googleapis.com/istio-build/dev/$TAG/istioctl-$TAG-win.zip + unzip istioctl-$TAG-win.zip + + ./istioctl install --set tag=$TAG --set hub=gcr.io/istio-testing + ``` + + 1. If you run the Endpoint Picker (EPP) with the `--secureServing` flag set to `true` (the default mode), it is currently using a self-signed certificate. As a security measure, Istio does not trust self-signed certificates by default. As a temporary workaround, you can apply the destination rule to bypass TLS verification for EPP. A more secure TLS implementation in EPP is being discussed in [Issue 582](https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/582). + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/destination-rule.yaml + ``` + + 1. Deploy Gateway + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/gateway.yaml + ``` + + 1. Label the gateway + + ```bash + kubectl label gateway llm-gateway istio.io/enable-inference-extproc=true + ``` - For high-traffic benchmarking you can apply this manifest to avoid any defaults that can cause timeouts/errors. + Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: + ```bash + $ kubectl get gateway inference-gateway + NAME CLASS ADDRESS PROGRAMMED AGE + inference-gateway inference-gateway True 22s + ``` + +=== "Kgateway" + + [Kgateway](https://kgateway.dev/) v2.0.0 adds support for inference extension as a **technical preview**. This means do not + run Kgateway with inference extension in production environments. Refer to [Issue 10411](https://github.com/kgateway-dev/kgateway/issues/10411) + for the list of caveats, supported features, etc. + + 1. Requirements + + - [Helm](https://helm.sh/docs/intro/install/) installed. + - Gateway API [CRDs](https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api) installed. + + 1. Install Kgateway CRDs + + ```bash + helm upgrade -i --create-namespace --namespace kgateway-system --version $VERSION kgateway-crds oci://cr.kgateway.dev/kgateway-dev/charts/kgateway-crds + ``` + + 1. Install Kgateway + + ```bash + helm upgrade -i --namespace kgateway-system --version $VERSION kgateway oci://cr.kgateway.dev/kgateway-dev/charts/kgateway + --set inferenceExtension.enabled=true + ``` + + 1. Deploy Gateway + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/gateway.yaml + ``` + + Confirm that the Gateway was assigned an IP address and reports a `Programmed=True` status: + ```bash + $ kubectl get gateway inference-gateway + NAME CLASS ADDRESS PROGRAMMED AGE + inference-gateway kgateway True 22s + ``` + +### Deploy the HTTPRoute ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/traffic_policy.yaml + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute.yaml ``` +### Configure Timeouts + + Given that default timeouts for above implementations may be insufficient for most inference workloads, it is recommended to configure a timeout appropriate for your intended use case. + +=== "GKE" + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml + ``` + +=== "Istio" + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute-with-timeout.yaml + ``` + +=== "Kgateway" + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute-with-timeout.yaml + ``` + ### Try it out Wait until the gateway is ready. ```bash IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') - PORT=8081 + PORT=80 curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ "model": "food-review", @@ -126,18 +240,32 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Cleanup The following cleanup assumes you would like to clean ALL resources that were created in this quickstart guide. - please be careful not to delete resources you'd like to keep. - ```bash - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/traffic_policy.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/extension_policy.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/patch_policy.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gateway.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/enable_patch_policy.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencepools.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml --ignore-not-found - kubectl delete secret hf-token --ignore-not-found - ``` + Please be careful not to delete resources you'd like to keep. + + 1. Uninstall the Inference Pool + + ```bash + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool-resources.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/cpu-deployment.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml --ignore-not-found + kubectl delete secret hf-token --ignore-not-found + ``` + + 1. Uninstall the Gateway + + ```bash + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gateway.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/healthcheck.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/gateway.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/destination-rule.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/gateway.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute.yaml --ignore-not-found + ``` + + 1. Uninstall the CRDs + + ```bash + kubectl delete -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd --ignore-not-found + ``` diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index f9dea1cc..643bbf75 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -75,7 +75,7 @@ const ( // inferModelManifest is the manifest for the inference model CRD. inferModelManifest = "../../../config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml" // inferExtManifest is the manifest for the inference extension test resources. - inferExtManifest = "../../../config/manifests/inferencepool.yaml" + inferExtManifest = "../../../config/manifests/inferencepool-resources.yaml" // envoyManifest is the manifest for the envoy proxy test resources. envoyManifest = "../../testdata/envoy.yaml" // modelServerManifestFilepathEnvVar is the env var that holds absolute path to the manifest for the model server test resource. From 2576b95b2a8153754bb4a0a1c88e6b94b852cf4e Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Fri, 28 Mar 2025 18:54:39 -0400 Subject: [PATCH 168/260] Add support for configuring ports in BBR helm chart (#601) --- config/charts/body-based-routing/README.md | 4 +++- config/charts/body-based-routing/templates/bbr.yaml | 8 ++++---- config/charts/body-based-routing/templates/gke.yaml | 4 ++-- config/charts/body-based-routing/templates/istio.yaml | 2 +- config/charts/body-based-routing/values.yaml | 3 ++- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/config/charts/body-based-routing/README.md b/config/charts/body-based-routing/README.md index 3c914dce..062f2b5c 100644 --- a/config/charts/body-based-routing/README.md +++ b/config/charts/body-based-routing/README.md @@ -40,12 +40,14 @@ The following table list the configurable parameters of the chart. |---------------------------------------------|----------------------------------------------------------------------------------------------------| | `bbr.name` | Name for the deployment and service. | | `bbr.replicas` | Number of replicas for the deployment. Defaults to `1`. | +| `bbr.port` | Port serving ext_proc. Defaults to `9004`. | +| `bbr.healthCheckPort` | Port for health checks. Defaults to `9005`. | | `bbr.image.name` | Name of the container image used. | | `bbr.image.hub` | Registry URL where the image is hosted. | | `bbr.image.tag` | Image tag. | | `bbr.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | | `provider.name` | Name of the Inference Gateway implementation being used. Possible values: `istio`, `gke`. Defaults to `none`. | -| `inference-gateway.name` | The name of the Gateway. Defaults to `inference-gateway`. | +| `inference-gateway.name` | The name of the Gateway. Defaults to `inference-gateway`. | ## Notes diff --git a/config/charts/body-based-routing/templates/bbr.yaml b/config/charts/body-based-routing/templates/bbr.yaml index e740e06e..ef08ae49 100644 --- a/config/charts/body-based-routing/templates/bbr.yaml +++ b/config/charts/body-based-routing/templates/bbr.yaml @@ -22,9 +22,9 @@ spec: - "-v" - "3" ports: - - containerPort: 9004 + - containerPort: {{ .Values.bbr.port }} # health check - - containerPort: 9005 + - containerPort: {{ .Values.bbr.healthCheckPort }} --- apiVersion: v1 kind: Service @@ -36,7 +36,7 @@ spec: app: {{ .Values.bbr.name }} ports: - protocol: TCP - port: 9004 - targetPort: 9004 + port: {{ .Values.bbr.port }} + targetPort: {{ .Values.bbr.port }} appProtocol: HTTP2 type: ClusterIP diff --git a/config/charts/body-based-routing/templates/gke.yaml b/config/charts/body-based-routing/templates/gke.yaml index db661bcf..937bfa0b 100644 --- a/config/charts/body-based-routing/templates/gke.yaml +++ b/config/charts/body-based-routing/templates/gke.yaml @@ -25,7 +25,7 @@ spec: group: "" kind: Service name: {{ .Values.bbr.name }} - port: 9004 + port: {{ .Values.bbr.port }} --- apiVersion: networking.gke.io/v1 kind: HealthCheckPolicy @@ -40,7 +40,7 @@ spec: type: "GRPC" grpcHealthCheck: portSpecification: "USE_FIXED_PORT" - port: 9005 + port: {{ .Values.bbr.healthCheckPort }} targetRef: group: "" kind: Service diff --git a/config/charts/body-based-routing/templates/istio.yaml b/config/charts/body-based-routing/templates/istio.yaml index 0f9f5f11..c4c1444f 100644 --- a/config/charts/body-based-routing/templates/istio.yaml +++ b/config/charts/body-based-routing/templates/istio.yaml @@ -31,7 +31,7 @@ spec: response_trailer_mode: "SKIP" grpc_service: envoy_grpc: - cluster_name: outbound|9004||{{ .Values.bbr.name }}.default.svc.cluster.local + cluster_name: outbound|{{ .Values.bbr.port }}||{{ .Values.bbr.name }}.default.svc.cluster.local --- apiVersion: networking.istio.io/v1 kind: DestinationRule diff --git a/config/charts/body-based-routing/values.yaml b/config/charts/body-based-routing/values.yaml index debd5f9e..b77d7542 100644 --- a/config/charts/body-based-routing/values.yaml +++ b/config/charts/body-based-routing/values.yaml @@ -6,7 +6,8 @@ bbr: hub: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension tag: main pullPolicy: Always - extProcPort: 9002 + port: 9004 + healthCheckPort: 9005 provider: name: none From cb98e2ffe0adbbad0a055775f207090b19154a8d Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:24:53 +0000 Subject: [PATCH 169/260] fix label selector on the ClusterPodMonitoring object (#611) --- config/charts/inferencepool/templates/gke.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/charts/inferencepool/templates/gke.yaml b/config/charts/inferencepool/templates/gke.yaml index 86e8c4ff..bc3d8239 100644 --- a/config/charts/inferencepool/templates/gke.yaml +++ b/config/charts/inferencepool/templates/gke.yaml @@ -55,5 +55,5 @@ spec: namespace: {{ .Release.Namespace }} selector: matchLabels: - {{- include "gateway-api-inference-extension.labels" . | nindent 8 }} + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 8 }} {{- end }} From e1ba762459058749e8da5d782bc13778908cffc2 Mon Sep 17 00:00:00 2001 From: Rob Scott Date: Fri, 28 Mar 2025 16:50:44 -0700 Subject: [PATCH 170/260] Removing Obsolete Portion of Metrics Guide (#608) --- site-src/guides/metrics.md | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index 12ff892e..fca43dd6 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -4,26 +4,7 @@ This guide describes the current state of exposed metrics and how to scrape them ## Requirements -To have response metrics, set the body mode to `Buffered` or `Streamed`: -``` -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: EnvoyExtensionPolicy -metadata: - name: ext-proc-policy - namespace: default -spec: - extProc: - - backendRefs: - - group: "" - kind: Service - name: inference-gateway-ext-proc - port: 9002 - processingMode: - request: - body: Buffered - response: - body: Buffered -``` +To have response metrics, ensure the body mode is set to `Buffered` or `Streamed` (this should be the default behavior for all implementations). If you want to include usage metrics for vLLM model server streaming request, send the request with `include_usage`: @@ -40,7 +21,7 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ ## Exposed metrics -| **Metric name** | **Metric Type** |
**Description**
|
**Labels**
| **Status** | +| **Metric name** | **Metric Type** |
**Description**
|
**Labels**
| **Status** | |:---------------------------------------------|:-----------------|:------------------------------------------------------------------|:-----------------------------------------------------------------------------------|:------------| | inference_model_request_total | Counter | The counter of requests broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_request_error_total | Counter | The counter of requests errors broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | From 7cd4460d233d5de1a9fb9bcd5d0097809636ee61 Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Fri, 28 Mar 2025 17:10:34 -0700 Subject: [PATCH 171/260] Allow defining a default base model in the lora syncer configuration (#609) --- config/manifests/vllm/gpu-deployment.yaml | 7 +- site-src/guides/adapter-rollout.md | 14 ++-- tools/dynamic-lora-sidecar/README.md | 69 ++++++++++++++++--- tools/dynamic-lora-sidecar/deployment.yaml | 17 ++--- tools/dynamic-lora-sidecar/sidecar/sidecar.py | 17 ++++- .../sidecar/validation.yaml | 11 +-- 6 files changed, 95 insertions(+), 40 deletions(-) diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index c405b33c..beb19bbd 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -246,11 +246,10 @@ data: vLLMLoRAConfig: name: vllm-llama3.1-8b-instruct port: 8000 + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct ensureExist: models: - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: food-review + - id: food-review source: Kawon/llama3.1-food-finetune_v14_r8 - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: cad-fabricator + - id: cad-fabricator source: redcathode/fabricator diff --git a/site-src/guides/adapter-rollout.md b/site-src/guides/adapter-rollout.md index 18d60ece..a398c124 100644 --- a/site-src/guides/adapter-rollout.md +++ b/site-src/guides/adapter-rollout.md @@ -33,13 +33,12 @@ Change the ConfigMap to match the following (note the new entry under models): vLLMLoRAConfig: name: vllm-llama3-8b-instruct-adapters port: 8000 + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct ensureExist: models: - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: food-review-1 + - id: food-review-1 source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: food-review-2 + - id: food-review-2 source: mahimairaja/tweet-summarization-llama-2-finetuned ``` @@ -118,15 +117,14 @@ Unload the older versions from the servers by updating the LoRA syncer ConfigMap vLLMLoRAConfig: name: sql-loras-llama port: 8000 + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct ensureExist: models: - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: food-review-2 + - id: food-review-2 source: mahimairaja/tweet-summarization-llama-2-finetuned ensureNotExist: models: - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: food-review-1 + - id: food-review-1 source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm ``` diff --git a/tools/dynamic-lora-sidecar/README.md b/tools/dynamic-lora-sidecar/README.md index f14dbfc7..bebaa885 100644 --- a/tools/dynamic-lora-sidecar/README.md +++ b/tools/dynamic-lora-sidecar/README.md @@ -60,20 +60,67 @@ The sidecar supports the following command-line arguments: ## Configuration Fields - `vLLMLoRAConfig`[**required**] base key -- `host` [*optional*]Model server's host. defaults to localhost +- `host` [*optional*] Model server's host. defaults to localhost - `port` [*optional*] Model server's port. defaults to 8000 -- `name`[*optional*] Name of this config -- `ensureExist`[*optional*] List of models to ensure existence on specified model server. - - `models`[**required**] [list] - - `base-model`[*optional*] Base model for lora adapter - - `id`[**required**] unique id of lora adapter - - `source`[**required**] path (remote or local) to lora adapter +- `name` [*optional*] Name of this config +- `defaultBaseModel` [*optional*] Default base model to use for all adapters when not specified individually +- `ensureExist` [*optional*] List of models to ensure existence on specified model server. + - `models` [**required**] [list] + - `id` [**required**] unique id of lora adapter + - `source` [**required**] path (remote or local) to lora adapter + - `base-model` [*optional*] Base model for lora adapter (overrides defaultBaseModel) - `ensureNotExist` [*optional*] - - `models`[**required**] [list] - - `id`[**required**] unique id of lora adapter - - `source`[**required**] path (remote or local) to lora adapter - - `base-model`[*optional*] Base model for lora adapter + - `models` [**required**] [list] + - `id` [**required**] unique id of lora adapter + - `source` [**required**] path (remote or local) to lora adapter + - `base-model` [*optional*] Base model for lora adapter (overrides defaultBaseModel) +## Example Configuration + +Here's an example of using the `defaultBaseModel` field to avoid repetition in your configuration: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-llama2-7b-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama2-7b + port: 8000 + defaultBaseModel: meta-llama/Llama-2-7b-hf + ensureExist: + models: + - id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + - id: tweet-summary-2 + source: mahimairaja/tweet-summarization-llama-2-finetuned +``` + +In this example, both adapters will use `meta-llama/Llama-2-7b-hf` as their base model without needing to specify it for each adapter individually. + +You can still override the default base model for specific adapters when needed: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-mixed-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-mixed + port: 8000 + defaultBaseModel: meta-llama/Llama-2-7b-hf + ensureExist: + models: + - id: tweet-summary-1 + source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + - id: code-assistant + source: huggingface/code-assistant-lora + base-model: meta-llama/Llama-2-13b-hf # Override for this specific adapter +``` ## Example Deployment The [deployment.yaml](deployment.yaml) file shows an example of deploying the sidecar with custom parameters: diff --git a/tools/dynamic-lora-sidecar/deployment.yaml b/tools/dynamic-lora-sidecar/deployment.yaml index 0a20ec66..0c0c1781 100644 --- a/tools/dynamic-lora-sidecar/deployment.yaml +++ b/tools/dynamic-lora-sidecar/deployment.yaml @@ -66,7 +66,7 @@ spec: - name: lora-adapter-syncer tty: true stdin: true - image: + image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/lora-syncer:main restartPolicy: Always imagePullPolicy: Always env: @@ -106,22 +106,17 @@ metadata: data: configmap.yaml: | vLLMLoRAConfig: - host: modelServerHost name: sql-loras-llama - port: modelServerPort + defaultBaseModel: meta-llama/Llama-2-7b-hf ensureExist: models: - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: sql-lora-v1 + - id: sql-lora-v1 source: yard1/llama-2-7b-sql-lora-test - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: sql-lora-v3 + - id: sql-lora-v3 source: yard1/llama-2-7b-sql-lora-test - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: sql-lora-v4 + - id: sql-lora-v4 source: yard1/llama-2-7b-sql-lora-test ensureNotExist: models: - - base-model: meta-llama/Llama-3.1-8B-Instruct - id: sql-lora-v2 + - id: sql-lora-v2 source: yard1/llama-2-7b-sql-lora-test \ No newline at end of file diff --git a/tools/dynamic-lora-sidecar/sidecar/sidecar.py b/tools/dynamic-lora-sidecar/sidecar/sidecar.py index 00de99e3..30724478 100644 --- a/tools/dynamic-lora-sidecar/sidecar/sidecar.py +++ b/tools/dynamic-lora-sidecar/sidecar/sidecar.py @@ -135,15 +135,24 @@ def port(self): def model_server(self): """Model server {host}:{port}""" return f"{self.host}:{self.port}" + + @property + def default_base_model(self): + """Default base model to use when not specified at adapter level""" + return self.config.get("defaultBaseModel", "") @property def ensure_exist_adapters(self): """Lora adapters in config under key `ensureExist` in set""" adapters = self.config.get("ensureExist", {}).get("models", set()) + default_model = self.default_base_model + return set( [ LoraAdapter( - adapter["id"], adapter["source"], adapter.get("base-model", "") + adapter["id"], + adapter["source"], + adapter.get("base-model", default_model) ) for adapter in adapters ] @@ -153,10 +162,14 @@ def ensure_exist_adapters(self): def ensure_not_exist_adapters(self): """Lora adapters in config under key `ensureNotExist` in set""" adapters = self.config.get("ensureNotExist", {}).get("models", set()) + default_model = self.default_base_model + return set( [ LoraAdapter( - adapter["id"], adapter["source"], adapter.get("base-model", "") + adapter["id"], + adapter["source"], + adapter.get("base-model", default_model) ) for adapter in adapters ] diff --git a/tools/dynamic-lora-sidecar/sidecar/validation.yaml b/tools/dynamic-lora-sidecar/sidecar/validation.yaml index 9dd98f87..30d23b7f 100644 --- a/tools/dynamic-lora-sidecar/sidecar/validation.yaml +++ b/tools/dynamic-lora-sidecar/sidecar/validation.yaml @@ -16,6 +16,9 @@ properties: name: type: string description: Name of this config + defaultBaseModel: + type: string + description: Default base model to use when not specified at adapter level ensureExist: type: object description: List of models to ensure existence on specified model server @@ -26,9 +29,9 @@ properties: items: type: object properties: - base_model: + base-model: type: string - description: Base model for LoRA adapter + description: Base model for LoRA adapter (overrides defaultBaseModel) id: type: string description: Unique ID of LoRA adapter @@ -50,9 +53,9 @@ properties: items: type: object properties: - base_model: + base-model: type: string - description: Base model for LoRA adapter + description: Base model for LoRA adapter (overrides defaultBaseModel) id: type: string description: Unique ID of LoRA adapter From 79fedb52b2df398b6a931bdac0f1bf20b6d79566 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Sat, 29 Mar 2025 00:10:40 +0000 Subject: [PATCH 172/260] add namespace parameter to ClusterPodMonitoring secret reference (#612) --- config/charts/inferencepool/Chart.yaml | 4 ++-- config/charts/inferencepool/templates/gke.yaml | 4 ++-- config/charts/inferencepool/values.yaml | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/config/charts/inferencepool/Chart.yaml b/config/charts/inferencepool/Chart.yaml index 0ce46e79..f98153c5 100644 --- a/config/charts/inferencepool/Chart.yaml +++ b/config/charts/inferencepool/Chart.yaml @@ -4,6 +4,6 @@ description: A Helm chart for InferencePool type: application -version: 0.1.0 +version: 0.0.0 -appVersion: "0.2.0" +appVersion: "0.0.0" diff --git a/config/charts/inferencepool/templates/gke.yaml b/config/charts/inferencepool/templates/gke.yaml index bc3d8239..220b3bea 100644 --- a/config/charts/inferencepool/templates/gke.yaml +++ b/config/charts/inferencepool/templates/gke.yaml @@ -50,9 +50,9 @@ spec: type: Bearer credentials: secret: - name: {{ .Values.gke.monitoringSecret }} + name: {{ .Values.gke.monitoringSecret.name }} key: token - namespace: {{ .Release.Namespace }} + namespace: {{ .Values.gke.monitoringSecret.namespace }} selector: matchLabels: {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 8 }} diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml index 45dd11a1..766ee087 100644 --- a/config/charts/inferencepool/values.yaml +++ b/config/charts/inferencepool/values.yaml @@ -17,4 +17,6 @@ provider: name: none gke: - monitoringSecret: inference-gateway-sa-metrics-reader-secret + monitoringSecret: + name: inference-gateway-sa-metrics-reader-secret + namespace: default From 4ff391b3c225cb35a4398eb6a862e5e19d9971ff Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Sat, 29 Mar 2025 19:58:34 +0000 Subject: [PATCH 173/260] Various fixes to docs and example manifests names (#613) --- README.md | 2 +- config/manifests/inferencemodel.yaml | 2 +- config/manifests/vllm/gpu-deployment.yaml | 4 +-- site-src/guides/adapter-rollout.md | 8 ++--- tools/dynamic-lora-sidecar/README.md | 39 +++++------------------ 5 files changed, 16 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 892ab8a5..2ff00581 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This project is [alpha (0.2 release)](https://github.com/kubernetes-sigs/gateway ## Getting Started -Follow our [Getting Started Guide](./pkg/README.md) to get the inference-extension up and running on your cluster! +Follow our [Getting Started Guide](https://gateway-api-inference-extension.sigs.k8s.io/guides/) to get the inference-extension up and running on your cluster! See our website at https://gateway-api-inference-extension.sigs.k8s.io/ for detailed API documentation on leveraging our Kubernetes-native declarative APIs diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index eaf05c75..5edb6001 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -1,7 +1,7 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: tweet-summarizer + name: food-review spec: modelName: food-review criticality: Standard diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index beb19bbd..3386a791 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -235,12 +235,12 @@ spec: emptyDir: {} - name: config-volume configMap: - name: vllm-llama3.1-8b-adapters + name: vllm-llama3-8b-adapters --- apiVersion: v1 kind: ConfigMap metadata: - name: vllm-llama3.1-8b-adapters + name: vllm-llama3-8b-adapters data: configmap.yaml: | vLLMLoRAConfig: diff --git a/site-src/guides/adapter-rollout.md b/site-src/guides/adapter-rollout.md index a398c124..fdf62c3a 100644 --- a/site-src/guides/adapter-rollout.md +++ b/site-src/guides/adapter-rollout.md @@ -37,9 +37,9 @@ Change the ConfigMap to match the following (note the new entry under models): ensureExist: models: - id: food-review-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + source: Kawon/llama3.1-food-finetune_v14_r8 - id: food-review-2 - source: mahimairaja/tweet-summarization-llama-2-finetuned + source: Kawon/llama3.1-food-finetune_v14_r8 ``` The new adapter version is applied to the model servers live, without requiring a restart. @@ -121,11 +121,11 @@ Unload the older versions from the servers by updating the LoRA syncer ConfigMap ensureExist: models: - id: food-review-2 - source: mahimairaja/tweet-summarization-llama-2-finetuned + source: Kawon/llama3.1-food-finetune_v14_r8 ensureNotExist: models: - id: food-review-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm + source: Kawon/llama3.1-food-finetune_v14_r8 ``` With this, all requests should be served by the new adapter version. diff --git a/tools/dynamic-lora-sidecar/README.md b/tools/dynamic-lora-sidecar/README.md index bebaa885..4e85fd92 100644 --- a/tools/dynamic-lora-sidecar/README.md +++ b/tools/dynamic-lora-sidecar/README.md @@ -77,50 +77,27 @@ The sidecar supports the following command-line arguments: ## Example Configuration -Here's an example of using the `defaultBaseModel` field to avoid repetition in your configuration: +In this example, both adapters will use `meta-llama/Llama-3.1-8B-Instruct` as their base model: ```yaml apiVersion: v1 kind: ConfigMap metadata: - name: vllm-llama2-7b-adapters + name: vllm-llama3-8b-adapters data: configmap.yaml: | vLLMLoRAConfig: - name: vllm-llama2-7b + name: vllm-llama3-8b port: 8000 - defaultBaseModel: meta-llama/Llama-2-7b-hf + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct ensureExist: models: - - id: tweet-summary-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - id: tweet-summary-2 - source: mahimairaja/tweet-summarization-llama-2-finetuned + - id: food-review-1 + source: Kawon/llama3.1-food-finetune_v14_r8 + - id: food-review-2 + source: Kawon/llama3.1-food-finetune_v14_r8 ``` -In this example, both adapters will use `meta-llama/Llama-2-7b-hf` as their base model without needing to specify it for each adapter individually. - -You can still override the default base model for specific adapters when needed: - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: vllm-mixed-adapters -data: - configmap.yaml: | - vLLMLoRAConfig: - name: vllm-mixed - port: 8000 - defaultBaseModel: meta-llama/Llama-2-7b-hf - ensureExist: - models: - - id: tweet-summary-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - id: code-assistant - source: huggingface/code-assistant-lora - base-model: meta-llama/Llama-2-13b-hf # Override for this specific adapter -``` ## Example Deployment The [deployment.yaml](deployment.yaml) file shows an example of deploying the sidecar with custom parameters: From f4c956cb416b2870b7928da2c97b83bce369dd99 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Sun, 30 Mar 2025 20:20:35 -0700 Subject: [PATCH 174/260] Docs: Quickstart Fixes (#615) - Fixes the InferencePool name reference in HTTPRoute. - Fixes target model name in InferenceModel. Signed-off-by: Daneyon Hansen --- config/manifests/gateway/httproute-with-timeout.yaml | 2 +- config/manifests/gateway/httproute.yaml | 2 +- config/manifests/inferencemodel.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/manifests/gateway/httproute-with-timeout.yaml b/config/manifests/gateway/httproute-with-timeout.yaml index 060f18c5..18e90ced 100644 --- a/config/manifests/gateway/httproute-with-timeout.yaml +++ b/config/manifests/gateway/httproute-with-timeout.yaml @@ -11,7 +11,7 @@ spec: - backendRefs: - group: inference.networking.x-k8s.io kind: InferencePool - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct matches: - path: type: PathPrefix diff --git a/config/manifests/gateway/httproute.yaml b/config/manifests/gateway/httproute.yaml index 5bd8bfb6..6ea90891 100644 --- a/config/manifests/gateway/httproute.yaml +++ b/config/manifests/gateway/httproute.yaml @@ -11,7 +11,7 @@ spec: - backendRefs: - group: inference.networking.x-k8s.io kind: InferencePool - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct matches: - path: type: PathPrefix diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 5edb6001..75c9bb17 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -8,7 +8,7 @@ spec: poolRef: name: vllm-llama3-8b-instruct targetModels: - - name: food-review-1 + - name: food-review weight: 100 --- From 1ce827390ca2c5d574953f10cd9901357efe233e Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 31 Mar 2025 10:58:46 -0700 Subject: [PATCH 175/260] Adding terminationGracePeriodSeconds to match vLLMs (#614) --- config/charts/inferencepool/templates/epp-deployment.yaml | 2 ++ config/manifests/inferencepool-resources.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/config/charts/inferencepool/templates/epp-deployment.yaml b/config/charts/inferencepool/templates/epp-deployment.yaml index 9faace73..d925a38e 100644 --- a/config/charts/inferencepool/templates/epp-deployment.yaml +++ b/config/charts/inferencepool/templates/epp-deployment.yaml @@ -16,6 +16,8 @@ spec: {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 8 }} spec: serviceAccountName: {{ include "gateway-api-inference-extension.name" . }} + # Conservatively, this timeout should mirror the longest grace period of the pods within the pool + terminationGracePeriodSeconds: 130 containers: - name: epp image: {{ .Values.inferenceExtension.image.hub }}/{{ .Values.inferenceExtension.image.name }}:{{ .Values.inferenceExtension.image.tag }} diff --git a/config/manifests/inferencepool-resources.yaml b/config/manifests/inferencepool-resources.yaml index d0f36e83..cef70d7f 100644 --- a/config/manifests/inferencepool-resources.yaml +++ b/config/manifests/inferencepool-resources.yaml @@ -42,6 +42,8 @@ spec: labels: app: vllm-llama3-8b-instruct-epp spec: + # Conservatively, this timeout should mirror the longest grace period of the pods within the pool + terminationGracePeriodSeconds: 130 containers: - name: epp image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/epp:main From 4d392ce78bc194c33de8926e64aea2f288f85132 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Mon, 31 Mar 2025 18:08:36 -0400 Subject: [PATCH 176/260] [Metrics] Add running requests gauge metric (#604) --- pkg/epp/handlers/server.go | 1 + pkg/epp/handlers/streamingserver.go | 9 ++- pkg/epp/metrics/metrics.go | 25 ++++++++ pkg/epp/metrics/metrics_test.go | 61 +++++++++++++++++++ .../metrics/testdata/running_requests_metrics | 4 ++ site-src/guides/metrics.md | 1 + 6 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 pkg/epp/metrics/testdata/running_requests_metrics diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index cd354c2f..a92f091c 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -228,6 +228,7 @@ type RequestContext struct { ResponseSize int ResponseComplete bool ResponseStatusCode string + RequestRunning bool RequestState StreamRequestState modelServerStreaming bool diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index d704578a..874dd734 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -81,13 +81,16 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // error metrics. This doesn't cover the error "Cannot receive stream request" because // such errors might happen even though response is processed. var err error - defer func(error) { + defer func(error, *RequestContext) { if reqCtx.ResponseStatusCode != "" { metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseStatusCode) } else if err != nil { metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, errutil.CanonicalCode(err)) } - }(err) + if reqCtx.RequestRunning { + metrics.DecRunningRequests(reqCtx.Model) + } + }(err, reqCtx) for { select { @@ -269,6 +272,8 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } r.RequestState = BodyRequestResponsesComplete + metrics.IncRunningRequests(r.Model) + r.RequestRunning = true // Dump the response so a new stream message can begin r.reqBodyResp = nil } diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index e86ca901..9ff2bb79 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -121,6 +121,16 @@ var ( []string{"model_name", "target_model_name"}, ) + runningRequests = compbasemetrics.NewGaugeVec( + &compbasemetrics.GaugeOpts{ + Subsystem: InferenceModelComponent, + Name: "running_requests", + Help: "Inference model number of running requests in each model.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"model_name"}, + ) + // Inference Pool Metrics inferencePoolAvgKVCache = compbasemetrics.NewGaugeVec( &compbasemetrics.GaugeOpts{ @@ -155,6 +165,7 @@ func Register() { legacyregistry.MustRegister(responseSizes) legacyregistry.MustRegister(inputTokens) legacyregistry.MustRegister(outputTokens) + legacyregistry.MustRegister(runningRequests) legacyregistry.MustRegister(inferencePoolAvgKVCache) legacyregistry.MustRegister(inferencePoolAvgQueueSize) @@ -209,6 +220,20 @@ func RecordOutputTokens(modelName, targetModelName string, size int) { } } +// IncRunningRequests increases the current running requests. +func IncRunningRequests(modelName string) { + if modelName != "" { + runningRequests.WithLabelValues(modelName).Inc() + } +} + +// DecRunningRequests decreases the current running requests. +func DecRunningRequests(modelName string) { + if modelName != "" { + runningRequests.WithLabelValues(modelName).Dec() + } +} + func RecordInferencePoolAvgKVCache(name string, utilization float64) { inferencePoolAvgKVCache.WithLabelValues(name).Set(utilization) } diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index c2436bab..dc4c7044 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -36,6 +36,7 @@ const ( ResponseSizesMetric = InferenceModelComponent + "_response_sizes" InputTokensMetric = InferenceModelComponent + "_input_tokens" OutputTokensMetric = InferenceModelComponent + "_output_tokens" + RunningRequestsMetric = InferenceModelComponent + "_running_requests" KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" ) @@ -345,6 +346,66 @@ func TestRecordResponseMetrics(t *testing.T) { } } +func TestRunningRequestsMetrics(t *testing.T) { + type request struct { + modelName string + complete bool // true -> request is completed, false -> running request + } + + scenarios := []struct { + name string + requests []request + }{ + { + name: "basic test", + requests: []request{ + { + modelName: "m1", + complete: false, + }, + { + modelName: "m1", + complete: false, + }, + { + modelName: "m1", + complete: true, + }, + { + modelName: "m2", + complete: false, + }, + }, + }, + } + + Register() + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + for _, req := range scenario.requests { + if req.complete { + DecRunningRequests(req.modelName) + } else { + IncRunningRequests(req.modelName) + } + } + + wantRunningRequests, err := os.Open("testdata/running_requests_metrics") + defer func() { + if err := wantRunningRequests.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRunningRequests, RunningRequestsMetric); err != nil { + t.Error(err) + } + }) + } +} + func TestInferencePoolMetrics(t *testing.T) { scenarios := []struct { name string diff --git a/pkg/epp/metrics/testdata/running_requests_metrics b/pkg/epp/metrics/testdata/running_requests_metrics new file mode 100644 index 00000000..a880e499 --- /dev/null +++ b/pkg/epp/metrics/testdata/running_requests_metrics @@ -0,0 +1,4 @@ +# HELP inference_model_running_requests [ALPHA] Inference model number of running requests in each model. +# TYPE inference_model_running_requests gauge +inference_model_running_requests{model_name="m1"} 1 +inference_model_running_requests{model_name="m2"} 1 diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index fca43dd6..d0747307 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -30,6 +30,7 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ | inference_model_response_sizes | Distribution | Distribution of response size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_input_tokens | Distribution | Distribution of input token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_output_tokens | Distribution | Distribution of output token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| inference_model_running_requests | Gauge | Number of running requests for each model. | `model_name`=<model-name> | ALPHA | | inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | | inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | From d3657582f1bd5e8989ab5a0fb31280d43afe0857 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Mon, 31 Mar 2025 19:54:35 -0400 Subject: [PATCH 177/260] [Metrics] Add number of ready pods metric for inference pool (#622) --- pkg/epp/backend/metrics/logger.go | 1 + pkg/epp/metrics/metrics.go | 15 +++++++++++++++ site-src/guides/metrics.md | 1 + test/integration/epp/hermetic_test.go | 13 ++++++++++++- 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/epp/backend/metrics/logger.go b/pkg/epp/backend/metrics/logger.go index 8c73d488..d71dc3fa 100644 --- a/pkg/epp/backend/metrics/logger.go +++ b/pkg/epp/backend/metrics/logger.go @@ -110,4 +110,5 @@ func flushPrometheusMetricsOnce(logger logr.Logger, datastore Datastore) { podTotalCount := len(podMetrics) metrics.RecordInferencePoolAvgKVCache(pool.Name, kvCacheTotal/float64(podTotalCount)) metrics.RecordInferencePoolAvgQueueSize(pool.Name, float64(queueTotal/podTotalCount)) + metrics.RecordinferencePoolReadyPods(pool.Name, float64(podTotalCount)) } diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 9ff2bb79..434b8381 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -151,6 +151,16 @@ var ( }, []string{"name"}, ) + + inferencePoolReadyPods = compbasemetrics.NewGaugeVec( + &compbasemetrics.GaugeOpts{ + Subsystem: InferencePoolComponent, + Name: "ready_pods", + Help: "The number of ready pods in the inference server pool.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"name"}, + ) ) var registerMetrics sync.Once @@ -169,6 +179,7 @@ func Register() { legacyregistry.MustRegister(inferencePoolAvgKVCache) legacyregistry.MustRegister(inferencePoolAvgQueueSize) + legacyregistry.MustRegister(inferencePoolReadyPods) }) } @@ -241,3 +252,7 @@ func RecordInferencePoolAvgKVCache(name string, utilization float64) { func RecordInferencePoolAvgQueueSize(name string, queueSize float64) { inferencePoolAvgQueueSize.WithLabelValues(name).Set(queueSize) } + +func RecordinferencePoolReadyPods(name string, runningPods float64) { + inferencePoolReadyPods.WithLabelValues(name).Set(runningPods) +} diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index d0747307..a781f721 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -33,6 +33,7 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ | inference_model_running_requests | Gauge | Number of running requests for each model. | `model_name`=<model-name> | ALPHA | | inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | | inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | +| inference_pool_ready_pods | Gauge | The number of ready pods for an inference server pool. | `name`=<inference-pool-name> | ALPHA | ## Scrape Metrics diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 8e02aca4..2acdacf8 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -430,7 +430,13 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. # TYPE inference_model_request_total counter inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 - `}, + `, + `inference_pool_ready_pods`: ` + # HELP inference_pool_ready_pods [ALPHA] The number of ready pods in the inference server pool. + # TYPE inference_pool_ready_pods gauge + inference_pool_ready_pods{name="vllm-llama3-8b-instruct-pool"} 3 + `, + }, wantErr: false, wantResponses: []*extProcPb.ProcessingResponse{ { @@ -1465,6 +1471,11 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, + wantMetrics: map[string]string{`inference_pool_ready_pods`: ` + # HELP inference_pool_ready_pods [ALPHA] The number of ready pods in the inference server pool. + # TYPE inference_pool_ready_pods gauge + inference_pool_ready_pods{name="vllm-llama3-8b-instruct-pool"} 1 + `}, }, } From 2a40268496b993ea30d98997909060aa4b4c6453 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Mon, 31 Mar 2025 17:10:35 -0700 Subject: [PATCH 178/260] Fixes Adapter ConfigMap Name Refs (#623) Signed-off-by: Daneyon Hansen --- config/manifests/vllm/gpu-deployment.yaml | 4 ++-- tools/dynamic-lora-sidecar/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index 3386a791..4f13736d 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -235,12 +235,12 @@ spec: emptyDir: {} - name: config-volume configMap: - name: vllm-llama3-8b-adapters + name: vllm-llama3-8b-instruct-adapters --- apiVersion: v1 kind: ConfigMap metadata: - name: vllm-llama3-8b-adapters + name: vllm-llama3-8b-instruct-adapters data: configmap.yaml: | vLLMLoRAConfig: diff --git a/tools/dynamic-lora-sidecar/README.md b/tools/dynamic-lora-sidecar/README.md index 4e85fd92..65dc0d78 100644 --- a/tools/dynamic-lora-sidecar/README.md +++ b/tools/dynamic-lora-sidecar/README.md @@ -83,7 +83,7 @@ In this example, both adapters will use `meta-llama/Llama-3.1-8B-Instruct` as th apiVersion: v1 kind: ConfigMap metadata: - name: vllm-llama3-8b-adapters + name: vllm-llama3-8b-instruct-adapters data: configmap.yaml: | vLLMLoRAConfig: From 39c1ff5e9d7bda6aef7b7eaedb767b5ca5865594 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Mon, 31 Mar 2025 17:26:35 -0700 Subject: [PATCH 179/260] Fixes Kgateway in Quickstart Guide (#616) * Fixes Kgateway in Quickstart Guide Signed-off-by: Daneyon Hansen * Moves HTTPRoutes to implementations Signed-off-by: Daneyon Hansen * Bumps kgtw to rc.2 Signed-off-by: Daneyon Hansen --------- Signed-off-by: Daneyon Hansen --- .../gateway/{ => gke}/httproute.yaml | 0 .../httproute.yaml} | 0 .../manifests/gateway/kgateway/httproute.yaml | 21 ++++ site-src/guides/index.md | 99 +++++++++++-------- 4 files changed, 79 insertions(+), 41 deletions(-) rename config/manifests/gateway/{ => gke}/httproute.yaml (100%) rename config/manifests/gateway/{httproute-with-timeout.yaml => istio/httproute.yaml} (100%) create mode 100644 config/manifests/gateway/kgateway/httproute.yaml diff --git a/config/manifests/gateway/httproute.yaml b/config/manifests/gateway/gke/httproute.yaml similarity index 100% rename from config/manifests/gateway/httproute.yaml rename to config/manifests/gateway/gke/httproute.yaml diff --git a/config/manifests/gateway/httproute-with-timeout.yaml b/config/manifests/gateway/istio/httproute.yaml similarity index 100% rename from config/manifests/gateway/httproute-with-timeout.yaml rename to config/manifests/gateway/istio/httproute.yaml diff --git a/config/manifests/gateway/kgateway/httproute.yaml b/config/manifests/gateway/kgateway/httproute.yaml new file mode 100644 index 00000000..03967729 --- /dev/null +++ b/config/manifests/gateway/kgateway/httproute.yaml @@ -0,0 +1,21 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: llm-route +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: inference-gateway + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: vllm-llama3-8b-instruct + port: 8000 # Remove when https://github.com/kgateway-dev/kgateway/issues/10987 is fixed. + matches: + - path: + type: PathPrefix + value: / + timeouts: + request: 300s diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 4548d5cd..7fdb211c 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -7,11 +7,12 @@ This quickstart guide is intended for engineers familiar with k8s and model servers (vLLM in this instance). The goal of this guide is to get an Inference Gateway up and running! ## **Prerequisites** - - A cluster with: - - Support for services of type `LoadBalancer`. (This can be validated by ensuring your Envoy Gateway is up and running). - For example, with Kind, you can follow [these steps](https://kind.sigs.k8s.io/docs/user/loadbalancer). - - Support for [sidecar containers](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/) (enabled by default since Kubernetes v1.29) - to run the model server deployment. + +- A cluster with: + - Support for services of type `LoadBalancer`. For kind clusters, follow [this guide](https://kind.sigs.k8s.io/docs/user/loadbalancer) + to get services of type LoadBalancer working. + - Support for [sidecar containers](https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/) (enabled by default since Kubernetes v1.29) + to run the model server deployment. ## **Steps** @@ -105,6 +106,24 @@ This quickstart guide is intended for engineers familiar with k8s and model serv inference-gateway inference-gateway True 22s ``` + 3. Deploy the HTTPRoute + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/httproute.yaml + ``` + + 4. Confirm that the HTTPRoute status conditions include `Accepted=True` and `ResolvedRefs=True`: + + ```bash + kubectl get httproute llm-route -o yaml + ``` + + 5. Given that the default connection timeout may be insufficient for most inference workloads, it is recommended to configure a timeout appropriate for your intended use case. + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml + ``` + === "Istio" Please note that this feature is currently in an experimental phase and is not intended for production use. @@ -114,7 +133,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv - Gateway API [CRDs](https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api) installed. - 1. Install Istio + 2. Install Istio ``` TAG=1.26-alpha.80c74f7f43482c226f4f4b10b4dda6261b67a71f @@ -131,19 +150,19 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ./istioctl install --set tag=$TAG --set hub=gcr.io/istio-testing ``` - 1. If you run the Endpoint Picker (EPP) with the `--secureServing` flag set to `true` (the default mode), it is currently using a self-signed certificate. As a security measure, Istio does not trust self-signed certificates by default. As a temporary workaround, you can apply the destination rule to bypass TLS verification for EPP. A more secure TLS implementation in EPP is being discussed in [Issue 582](https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/582). + 3. If you run the Endpoint Picker (EPP) with the `--secureServing` flag set to `true` (the default mode), it is currently using a self-signed certificate. As a security measure, Istio does not trust self-signed certificates by default. As a temporary workaround, you can apply the destination rule to bypass TLS verification for EPP. A more secure TLS implementation in EPP is being discussed in [Issue 582](https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/582). ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/destination-rule.yaml ``` - 1. Deploy Gateway + 4. Deploy Gateway ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/gateway.yaml ``` - 1. Label the gateway + 5. Label the gateway ```bash kubectl label gateway llm-gateway istio.io/enable-inference-extproc=true @@ -156,9 +175,21 @@ This quickstart guide is intended for engineers familiar with k8s and model serv inference-gateway inference-gateway True 22s ``` + 6. Deploy the HTTPRoute + + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/httproute.yaml + ``` + + 7. Confirm that the HTTPRoute status conditions include `Accepted=True` and `ResolvedRefs=True`: + + ```bash + kubectl get httproute llm-route -o yaml + ``` + === "Kgateway" - [Kgateway](https://kgateway.dev/) v2.0.0 adds support for inference extension as a **technical preview**. This means do not + [Kgateway](https://kgateway.dev/) recently added support for inference extension as a **technical preview**. This means do not run Kgateway with inference extension in production environments. Refer to [Issue 10411](https://github.com/kgateway-dev/kgateway/issues/10411) for the list of caveats, supported features, etc. @@ -167,20 +198,20 @@ This quickstart guide is intended for engineers familiar with k8s and model serv - [Helm](https://helm.sh/docs/intro/install/) installed. - Gateway API [CRDs](https://gateway-api.sigs.k8s.io/guides/#installing-gateway-api) installed. - 1. Install Kgateway CRDs + 2. Set the Kgateway version and install the Kgateway CRDs. ```bash - helm upgrade -i --create-namespace --namespace kgateway-system --version $VERSION kgateway-crds oci://cr.kgateway.dev/kgateway-dev/charts/kgateway-crds + KGTW_VERSION=v2.0.0-rc.2 + helm upgrade -i --create-namespace --namespace kgateway-system --version $KGTW_VERSION kgateway-crds oci://cr.kgateway.dev/kgateway-dev/charts/kgateway-crds ``` - 1. Install Kgateway + 3. Install Kgateway ```bash - helm upgrade -i --namespace kgateway-system --version $VERSION kgateway oci://cr.kgateway.dev/kgateway-dev/charts/kgateway - --set inferenceExtension.enabled=true + helm upgrade -i --namespace kgateway-system --version $KGTW_VERSION kgateway oci://cr.kgateway.dev/kgateway-dev/charts/kgateway --set inferenceExtension.enabled=true ``` - 1. Deploy Gateway + 4. Deploy the Gateway ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/gateway.yaml @@ -193,33 +224,17 @@ This quickstart guide is intended for engineers familiar with k8s and model serv inference-gateway kgateway True 22s ``` -### Deploy the HTTPRoute + 5. Deploy the HTTPRoute - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute.yaml - ``` - -### Configure Timeouts - - Given that default timeouts for above implementations may be insufficient for most inference workloads, it is recommended to configure a timeout appropriate for your intended use case. - -=== "GKE" - - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml - ``` - -=== "Istio" + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/httproute.yaml + ``` - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute-with-timeout.yaml - ``` + 6. Confirm that the HTTPRoute status conditions include `Accepted=True` and `ResolvedRefs=True`: -=== "Kgateway" - - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute-with-timeout.yaml - ``` + ```bash + kubectl get httproute llm-route -o yaml + ``` ### Try it out @@ -258,10 +273,12 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gateway.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/healthcheck.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/httproute.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/gateway.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/destination-rule.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/istio/httproute.yaml --ignore-not-found kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/gateway.yaml --ignore-not-found - kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/httproute.yaml --ignore-not-found + kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/httproute.yaml --ignore-not-found ``` 1. Uninstall the CRDs From 580c3c84d5f20e42251249a9ca89a9e7699108f0 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 31 Mar 2025 18:22:35 -0700 Subject: [PATCH 180/260] Update release script (#625) --- hack/release-quickstart.sh | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/hack/release-quickstart.sh b/hack/release-quickstart.sh index 832bd872..c60682e1 100755 --- a/hack/release-quickstart.sh +++ b/hack/release-quickstart.sh @@ -36,19 +36,22 @@ sed -i.bak -E "s|(releases/download/)v[0-9]+\.[0-9]+\.0-rc\.?[0-9]+|\1${RELEASE_ sed -i.bak "s|kubectl apply -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd|kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/${RELEASE_TAG}/manifests.yaml|g" "$README" # ----------------------------------------------------------------------------- -# Update config/manifests/ext_proc.yaml +# Update EPP image references # ----------------------------------------------------------------------------- -EXT_PROC="config/manifests/ext_proc.yaml" -echo "Updating ${EXT_PROC} ..." +EPP="config/manifests/inferencepool-resources.yaml" +HELM_VALUES="config/charts/inferencepool/values.yaml" +echo "Updating ${EPP} & ${HELM_VALUES} ..." # Update the EPP container tag. -sed -i.bak -E "s|(us-central1-docker\.pkg\.dev/k8s-staging-images/gateway-api-inference-extension/epp:)[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$EXT_PROC" +sed -i.bak -E "s|(us-central1-docker\.pkg\.dev/k8s-staging-images/gateway-api-inference-extension/epp:)[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$EPP" +sed -i.bak -E "s|(tag: )[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$HELM_VALUES" # Update the EPP container image pull policy. -sed -i.bak '/us-central1-docker.pkg.dev\/k8s-staging-images\/gateway-api-inference-extension\/epp/ { n; s/Always/IfNotPresent/ }' "$EXT_PROC" +sed -i.bak '/us-central1-docker.pkg.dev\/k8s-staging-images\/gateway-api-inference-extension\/epp/ { n; s/Always/IfNotPresent/ }' "$EPP" # Update the EPP container registry. -sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EXT_PROC" +sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EPP" +sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$HELM_VALUES" # ----------------------------------------------------------------------------- # Update config/manifests/vllm/gpu-deployment.yaml @@ -65,8 +68,8 @@ sed -i.bak '/vllm\/vllm-openai/ { n; s/Always/IfNotPresent/ }' "$VLLM_DEPLOY" # ----------------------------------------------------------------------------- # Stage the changes # ----------------------------------------------------------------------------- -echo "Staging $README $EXT_PROC $VLLM_DEPLOY files..." -git add $README $EXT_PROC $VLLM_DEPLOY +echo "Staging $README $EPP $HELM_VALUES $VLLM_DEPLOY files..." +git add $README $EPP $HELM_VALUES $VLLM_DEPLOY # ----------------------------------------------------------------------------- # Cleanup backup files and finish From a07e01f54824302e493b83c1812729aee91f5bac Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 31 Mar 2025 19:56:55 -0700 Subject: [PATCH 181/260] More release updates (#628) --- hack/push-chart.sh | 2 +- hack/release-quickstart.sh | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/hack/push-chart.sh b/hack/push-chart.sh index e0938af4..36ed92cd 100755 --- a/hack/push-chart.sh +++ b/hack/push-chart.sh @@ -30,7 +30,7 @@ CHART=${CHART:-inferencepool} HELM=${HELM:-./bin/helm} -readonly semver_regex='^v([0-9]+)(\.[0-9]+){1,2}$' +readonly semver_regex='^v([0-9]+)(\.[0-9]+){1,2}(-rc.[0-9]+)?$' chart_version=${CHART_VERSION} if [[ ${EXTRA_TAG} =~ ${semver_regex} ]] diff --git a/hack/release-quickstart.sh b/hack/release-quickstart.sh index c60682e1..c2c0f74d 100755 --- a/hack/release-quickstart.sh +++ b/hack/release-quickstart.sh @@ -36,22 +36,26 @@ sed -i.bak -E "s|(releases/download/)v[0-9]+\.[0-9]+\.0-rc\.?[0-9]+|\1${RELEASE_ sed -i.bak "s|kubectl apply -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd|kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/${RELEASE_TAG}/manifests.yaml|g" "$README" # ----------------------------------------------------------------------------- -# Update EPP image references +# Update image references # ----------------------------------------------------------------------------- EPP="config/manifests/inferencepool-resources.yaml" -HELM_VALUES="config/charts/inferencepool/values.yaml" -echo "Updating ${EPP} & ${HELM_VALUES} ..." +#TODO: Put all helm values files into an array to loop over +EPP_HELM="config/charts/inferencepool/values.yaml" +BBR_HELM="config/charts/body-based-routing/values.yaml" +echo "Updating ${EPP} & ${EPP_HELM} ..." -# Update the EPP container tag. +# Update the container tag. sed -i.bak -E "s|(us-central1-docker\.pkg\.dev/k8s-staging-images/gateway-api-inference-extension/epp:)[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$EPP" -sed -i.bak -E "s|(tag: )[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$HELM_VALUES" +sed -i.bak -E "s|(tag: )[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$EPP_HELM" +sed -i.bak -E "s|(tag: )[^\"[:space:]]+|\1${RELEASE_TAG}|g" "$BBR_HELM" -# Update the EPP container image pull policy. +# Update the container image pull policy. sed -i.bak '/us-central1-docker.pkg.dev\/k8s-staging-images\/gateway-api-inference-extension\/epp/ { n; s/Always/IfNotPresent/ }' "$EPP" -# Update the EPP container registry. +# Update the container registry. sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EPP" -sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$HELM_VALUES" +sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$EPP_HELM" +sed -i.bak -E "s|us-central1-docker\.pkg\.dev/k8s-staging-images|registry.k8s.io|g" "$BBR_HELM" # ----------------------------------------------------------------------------- # Update config/manifests/vllm/gpu-deployment.yaml @@ -68,8 +72,8 @@ sed -i.bak '/vllm\/vllm-openai/ { n; s/Always/IfNotPresent/ }' "$VLLM_DEPLOY" # ----------------------------------------------------------------------------- # Stage the changes # ----------------------------------------------------------------------------- -echo "Staging $README $EPP $HELM_VALUES $VLLM_DEPLOY files..." -git add $README $EPP $HELM_VALUES $VLLM_DEPLOY +echo "Staging $README $EPP $EPP_HELM $BBR_HELM $VLLM_DEPLOY files..." +git add $README $EPP $EPP_HELM $BBR_HELM $VLLM_DEPLOY # ----------------------------------------------------------------------------- # Cleanup backup files and finish From 2f18756707a7e611260194a52c3d577ccbf91b5e Mon Sep 17 00:00:00 2001 From: Rob Scott Date: Tue, 1 Apr 2025 16:30:38 +0100 Subject: [PATCH 182/260] Adding larger logo (#630) --- site-src/images/logo/logo-text-xl-dark.png | Bin 0 -> 88763 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 site-src/images/logo/logo-text-xl-dark.png diff --git a/site-src/images/logo/logo-text-xl-dark.png b/site-src/images/logo/logo-text-xl-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4d878e5c807084c5f39dcd57e8d72c787ceb3d24 GIT binary patch literal 88763 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 Z0^(PiHN6m7b-)N*#JypA_`4F${|DjoQuqJ> literal 0 HcmV?d00001 From 40595e011caccd7da491fa264be84ed694c36a3b Mon Sep 17 00:00:00 2001 From: Nicole Xin Date: Tue, 1 Apr 2025 09:32:45 -0700 Subject: [PATCH 183/260] Minor fixes to the user guide (#633) * Update InferencePool name in healthcheck.yaml * Update curl command for CPU based model --- config/manifests/gateway/gke/healthcheck.yaml | 2 +- site-src/guides/index.md | 38 +++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/config/manifests/gateway/gke/healthcheck.yaml b/config/manifests/gateway/gke/healthcheck.yaml index 95f4f2d2..93b6cd7f 100644 --- a/config/manifests/gateway/gke/healthcheck.yaml +++ b/config/manifests/gateway/gke/healthcheck.yaml @@ -7,7 +7,7 @@ spec: targetRef: group: "inference.networking.x-k8s.io" kind: InferencePool - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct default: config: type: HTTP diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 7fdb211c..f1545438 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -240,17 +240,33 @@ This quickstart guide is intended for engineers familiar with k8s and model serv Wait until the gateway is ready. - ```bash - IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') - PORT=80 - - curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ - "model": "food-review", - "prompt": "Write as if you were a critic: San Francisco", - "max_tokens": 100, - "temperature": 0 - }' - ``` +=== "GPU-Based Model Server" + + ```bash + IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') + PORT=80 + + curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ + "model": "food-review", + "prompt": "Write as if you were a critic: San Francisco", + "max_tokens": 100, + "temperature": 0 + }' + ``` + +=== "CPU-Based Model Server" + + ```bash + IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}') + PORT=80 + + curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ + "model": "Qwen/Qwen2.5-1.5B-Instruct", + "prompt": "Write as if you were a critic: San Francisco", + "max_tokens": 100, + "temperature": 0 + }' + ``` ### Cleanup From 419aba9605753e7250bfac2d339b3da4868c29a8 Mon Sep 17 00:00:00 2001 From: Lior Lieberman Date: Tue, 1 Apr 2025 12:02:37 -0700 Subject: [PATCH 184/260] Add istio to implementations.md (#631) * Add istio to implementations.md * fixes --- site-src/implementations.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/site-src/implementations.md b/site-src/implementations.md index 89acb436..8a95119d 100644 --- a/site-src/implementations.md +++ b/site-src/implementations.md @@ -54,3 +54,12 @@ Issue](https://github.com/GoogleCloudPlatform/gke-gateway-api/issues/20). [gke-gateway]:https://cloud.google.com/kubernetes-engine/docs/concepts/gateway-api [gke-gateway-deploy]:https://cloud.google.com/kubernetes-engine/docs/how-to/deploying-gateways [gke-multi-cluster-gateway]:https://cloud.google.com/kubernetes-engine/docs/how-to/deploying-multi-cluster-gateways + +## Istio + +[Istio](https://istio.io/) is an open source service mesh and gateway implementation. +It provides a fully compliant implementation of the Kubernetes Gateway API for cluster ingress traffic control. +For service mesh users, Istio also fully supports east-west (including [GAMMA](https://gateway-api.sigs.k8s.io/mesh/)) traffic management within the mesh. + +Gateway API Inference Extension support is being tracked by this [GitHub +Issue](https://github.com/istio/istio/issues/55768). From 110f490bdf2dd55230e5c387d8de091c406e5d61 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Tue, 1 Apr 2025 12:50:46 -0700 Subject: [PATCH 185/260] Update e2e test config (#636) --- test/e2e/epp/e2e_test.go | 4 ++-- test/testdata/envoy.yaml | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/test/e2e/epp/e2e_test.go b/test/e2e/epp/e2e_test.go index 09c8835a..e86b2d49 100644 --- a/test/e2e/epp/e2e_test.go +++ b/test/e2e/epp/e2e_test.go @@ -94,11 +94,11 @@ var _ = ginkgo.Describe("InferencePool", func() { func newInferenceModel(ns string) *v1alpha2.InferenceModel { targets := []v1alpha2.TargetModel{ { - Name: modelName + "-0", + Name: modelName, Weight: ptr.To(int32(50)), }, { - Name: modelName + "-1", + Name: "cad-fabricator", Weight: ptr.To(int32(50)), }, } diff --git a/test/testdata/envoy.yaml b/test/testdata/envoy.yaml index fc32b5aa..62e6b4c5 100644 --- a/test/testdata/envoy.yaml +++ b/test/testdata/envoy.yaml @@ -104,10 +104,11 @@ data: timeout: 10s processing_mode: request_header_mode: SEND - response_header_mode: SKIP - request_body_mode: BUFFERED - request_trailer_mode: SKIP - response_trailer_mode: SKIP + response_header_mode: SEND + request_body_mode: FULL_DUPLEX_STREAMED + response_body_mode: FULL_DUPLEX_STREAMED + request_trailer_mode: SEND + response_trailer_mode: SEND message_timeout: 1000s # Mark it as disabled if needed for troubleshooting: # disabled: true @@ -221,7 +222,7 @@ spec: spec: containers: - name: envoy - image: docker.io/envoyproxy/envoy:distroless-v1.32.2 + image: docker.io/envoyproxy/envoy:distroless-v1.33.2 args: - "--service-cluster" - "default/inference-gateway" From ae858abe1b5a4954443a6b6220cd0177b089d9b2 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Tue, 1 Apr 2025 18:22:42 -0400 Subject: [PATCH 186/260] Fix parsing issue in BBR helm (#638) --- config/charts/body-based-routing/README.md | 2 +- config/charts/body-based-routing/templates/gke.yaml | 2 +- config/charts/body-based-routing/values.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/charts/body-based-routing/README.md b/config/charts/body-based-routing/README.md index 062f2b5c..a6b8d3cd 100644 --- a/config/charts/body-based-routing/README.md +++ b/config/charts/body-based-routing/README.md @@ -47,7 +47,7 @@ The following table list the configurable parameters of the chart. | `bbr.image.tag` | Image tag. | | `bbr.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | | `provider.name` | Name of the Inference Gateway implementation being used. Possible values: `istio`, `gke`. Defaults to `none`. | -| `inference-gateway.name` | The name of the Gateway. Defaults to `inference-gateway`. | +| `inferenceGateway.name` | The name of the Gateway. Defaults to `inference-gateway`. | ## Notes diff --git a/config/charts/body-based-routing/templates/gke.yaml b/config/charts/body-based-routing/templates/gke.yaml index 937bfa0b..77b776a4 100644 --- a/config/charts/body-based-routing/templates/gke.yaml +++ b/config/charts/body-based-routing/templates/gke.yaml @@ -9,7 +9,7 @@ spec: targetRefs: - group: "gateway.networking.k8s.io" kind: Gateway - name: {{ .Values.inference-gateway.name }} + name: {{ .Values.inferenceGateway.name }} extensionChains: - name: chain1 extensions: diff --git a/config/charts/body-based-routing/values.yaml b/config/charts/body-based-routing/values.yaml index b77d7542..0b88dc43 100644 --- a/config/charts/body-based-routing/values.yaml +++ b/config/charts/body-based-routing/values.yaml @@ -12,5 +12,5 @@ bbr: provider: name: none -inference-gateway: +inferenceGateway: name: inference-gateway From 740be256da42f717a7effbd11aced9ce1532f19c Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 2 Apr 2025 05:12:39 +0300 Subject: [PATCH 187/260] fixed bug - sleep is expecting to get a string (#618) * fixed bug - sleep is expecting to get a string Signed-off-by: Nir Rozenbaum * remove space Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- config/manifests/vllm/gpu-deployment.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index 4f13736d..e7cb193e 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -77,7 +77,7 @@ spec: #exec: # command: # - /usr/bin/sleep - # - 30 + # - "30" livenessProbe: httpGet: path: /health @@ -133,7 +133,6 @@ spec: path: /health port: http scheme: HTTP - resources: limits: nvidia.com/gpu: 1 From 8e793c21047d996db038aa2c92830d047f52f28d Mon Sep 17 00:00:00 2001 From: Conor O'Callaghan <4090256+Conor0Callaghan@users.noreply.github.com> Date: Wed, 2 Apr 2025 04:32:36 +0100 Subject: [PATCH 188/260] #632 Add favicon for doc site (#634) --- mkdocs.yml | 2 +- site-src/images/favicon-64.png | Bin 0 -> 5013 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 site-src/images/favicon-64.png diff --git a/mkdocs.yml b/mkdocs.yml index 2dc4d2a1..b67cf8b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,7 +10,7 @@ theme: icon: repo: fontawesome/brands/git-alt logo: images/logo/logo-text-large-horizontal-white.png - favicon: images/k8s-favicon.png + favicon: images/favicon-64.png features: - search.highlight - navigation.tabs diff --git a/site-src/images/favicon-64.png b/site-src/images/favicon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..f2bd3d64a81c830be8b6349c3e78a963774fd2dd GIT binary patch literal 5013 zcmV;G6Kd>gw)da;@yOs#n*od(Sz0pS{2R z?Q_oc;(HXOl=EvDfc5|)0CqcnaJgLH=dez_P;tknhf?YZrPMa1R75FtSSj^^QfjJF zsxH7>i9Qom73wg0y@M3&P03{*3~}5oWGT*RyHqQFscF|z5QXP0 z3FGr`;z%SF3LJ`OOUeS`Nrkp$9$ax=5Mzgh(6Lp9A!-2p4Zth_YiuP>z!1m$WR~Kb z;W1kAk>+AD1uR<^#mh?~_-bohX16KDv&9&K;V@=k5aWi2aC+N}j9m-hH2_ZlSec>F zF&ZQ9yR#Jc>)2+DpKmU5&>wys#k{2vtlkj^9}!nSrXkgfr43P5GzK>7 z8N4r^P$({Ry3AkP98P7-P=7oNjghwI;dA2z*h6n{*7}+;~pN@x~7h790cZHUEu z1kD(s(OC?FM|F{2iSbae6y1{&R8*9H~y> z&SxtTiK@JekRBTlODY5lTo~FbfQh3*7|^XC`?jSzYb-z$M#y6H4X##6KLynjP`Hy{CShX^ddF4e9B}Pe+9? zq=!E{q+S3ph27>dLSw}c1MGRD{LzRdZvMZY)?)ABlqel2IDypT0uEKB^C=R~rosplAO&o!&&@+n=exx?KtB>_ikd z;&B7}j-(`}8BRyTmW0vqifsr0D>laP`KB1I7+i>7Utfx#U&cF4d%~LRXB+GGCiBmq z|5=T-I}-v}o)Z{k=73$Osk6j==>gG%Iw_o-Z4~%i;-RlDuf@{~Ys^msh%ve9iCij` z8KmbR|FtcCQf8ff6Y;5RBx<2kD=!K>E|i7bC@pj&5>sLj%R1q%#3Klhi@vH89kiU6 z>)u=#&<#L4{5?LJDEH*(Cb!UFm!B8JPX+}A)fvYOLmJM^G{KXhGIdNb zM)wcme?E)|Cuv|PS6YYhBjg*caBj8%vK^Q=_2WNu+i&(J;LW~6&wk%71!z<1!Clvt zh@!N%rDwfEOD~?dwH)_fU)n(VL`q@Lp`@@#BMdW>7VWXSfT6woc=ooIm@=kVjLvW} zAvOK-V%&OJF}k$%VsH;Xa;=we>}06K7A=FbfiT+xI%+q;zKRsSJ(7Ya3jjDU4&dSW zRk&|liMaK>V@qYm|F$fG-lzHSho84Vr`DNGZ`zZ<+h5jU#ip3F`}Fj3^gTVz-v04f zE#|%(M(-{@jOrV}rTv2T!U)5U%2LsX=O2}6opDt$CS6=4zjKnFSya7p zx7lBj!c%Y8;N7q4WY+0DbmySI{vM{-%CIHIq1!+IUKlrCSR`#5cF2$*eJ&2T(7BBl z;W~x6i)--rk7^N)S~;mWCyP4J3$y{c18B=(nS1O}XxFe)#hRT7ap}f{@kV!ldSMtd zW>-i)XD3E^HuurGC?-9A7_WU&EBv3SiN^DLUGl$?qJRq?Pr6NBKU8Jmu~)0`%cm=` z=Wx=#uNN~&8?S`SEwheD-vq#>>JW}Yuq-W1@x7eCXGi!T^U#J}iDpwAy^c)lvyCy~ z@=oEbaDY3$ONvrUA52K_EvS%;OIzJYjeV%hh0a_HfoAl6n1Gup3KnjPeZII0%L{0;oX$a8v{RuHb zKKz`Jk*LD-D~fH`?%KhNzf3R3n=9(jqmwU#xe+{> zI_dVz=}uvr_dQ>U4_8O!d;hNK)Y-G6Pe73*skwCo>aupHZ9GGW*1{12<|6813S0Ii za^}@+@p>Q&5Jp&&Q+dYs>_s(r=;fp0_C|Pp?65*ZO>JRU7o8Jmp#1JbNlbg{2tM5y zLrKUjV9T@$?b<_n`fY4!dYmq7-IqbWY(iuVV7mo7Jy4lyw)ADZ(sh5>zTSzL!)a}P zNMPs~J?~+60Il z*Tz-zE43CR=M#-7EOWQ*&-5IozxP%}@vCPm#j~73Pg(e7ow#=PEN=cbfoV@xVAsK9 zMz-Ljt=nbCvTT~LAZsog*oDz<``I%!A%a`G!XWdDkcK>fh0m2Zz>Kq8>Bt;Zitf+B z?Kzy1MNA{rwHEGv?kH}bQ;BI$RS1=JDsiyN!ojMP&HD||QIWk4VpLk_7Tm~d-&6`C z+k5PkZ~S+l88kg0pOqh_W6O97Ei9`$!pwlyr5@o1jMJfK`DNk52+{nD%`sts?DN%+ zYcXMD$iC!bVKJstD+$pDG(fqr z2IGX()04+auS*javmn7d-mQaAiXoQCeYzCLVn;4v>STB@kH=F$yK)aog7x<@iu!de zkPkhBq43p~xKL~7+64Q~2Lz!B5sNT_Xy8n`e6jEN<8?*kBb^5T{H#_#?N&_3)5TbTG%&Jaa`{< zyEOnewn9`>XW^T@Nn?QCEJM6H|H~ETe=6zAdYG1`(wtGoU1`WImD!|=L$(Xj7>%V) zORony@4a$xAubyb%xN%k2*!3Uae4s9@!-TVsUE+pObKDCjakAbbA<+hn>cXExkB7J z0q91>heO9S$wKx_7<>Qfni5<#Fo@!yt4R+b(>r+7l2F?IT@u^(C#A07pdP-dOvsCo zTn!hZ9*FURk zfMwK;X_XV=cF6|s1vJ1p{4p9+5+m*`|4|l%gj_i!X!Ct4#+*;SFs!#9=XFmL7%Dw= zR)rKER9IGcT>v2^mQ2TTug=;2l_{*<_Z&(|+XO&pkD;+hrT;vo+|KqOIXN3*DyQ&Vqf3*rrR!0SN*icc>EzEK0+B8q+;Anl;2F|?Ba+q=FOogkq z#&P|lhh*2uNtbD)t{r@0T<7z>yZA6}m|et)2X6l^Azn#ivoKq}A&Mv7sKN5}(VU=4 z-B?RF$ki&|_J;ZPi_t|AbGa%(f(n%`@MWI#;J+`v9~LG^Is1$P{Opn<#57tHKOSvSn`W4ftHL?pPE|cg*p<@P0Uh0C7xajJdFd z7}2@4N5-b58o+$F-UOACn(bP6WD%k;;Lbb0va_|j5}3aw_efNV^d!q~wbSC`&woC{tUR8(3Uab~xPdD%0(T8>|+_IX(@i6`mRi&g-TeU4NRq3sl z70db|Dei*hkt}}NxG?S~jtV(R)0Y6I(lK(KLu%@j808rN{~tQGXUwjYh0o?dk%M3c zc;c3FRMlFz?stb|2@Kjj-FoLIO>139rIfvSVi+9h@q%BsM(Yv}?wVUE=MnSi_xv8| zdO_d2UmxM!+}I(koKfw#`E~#wLFf9J6H8?gG~UFX0Tpah>XGV^$}C?Wlk~;sZ8qsm zFJqA-kkwgwyUhH?4>Ja7H-~b^H6=n|nj&Xx=d{*dAv;?6m=5L5O&P6GSkp#`=mS$b z`Gtx-a#NY`@5Tm0<6PP=V0L4L_2sg?%a!?QFfbRebdA7}aDveTf{hxUV+s1*Qf{}e zKaX&aJ~i6p5xgdGT|dZT>1J&VsR-{KSE}FQX!r~>1@VF|&l&u5V=QmV>J)keD^KDF z@nQ4cWX>!ki#3*#?Rm3lqC@vjIMK7h$i#!m0Opu4fBZ)^=DeF56geYhMQ&LfN#=PD zIF~RqylzO_EmEoe>VS zDs0J{AjhjbbQsW>M;lR|eUoO-xT?fl+Mz=>FXfSs0g!2g`v5G0cF~zPl%ZRP+@MGR zJQ=U{q*t69UMJ`gXC9nbCY2t5uvUKeu^hY2`|PJ~l&JLoO!3sJ)Qv}OZXpFsGL?6q zCoFQ*K9Jsecf4D~igx+2wslO0U$qffK2IcJ&%P7Jmi>v7a@;kSaIh_k-MI0>5Uw9?GrffXu4V>4 zt`q0SGZ2Rn9suxQ{q=MODNAZ6A^tNvP47Oja&%;#^}4)0@V&op?0BQ|p6s_mzb9Ft zl Date: Tue, 1 Apr 2025 23:50:36 -0400 Subject: [PATCH 189/260] Move integration test utils to central package (#626) * Move integration test utils to central package * Move integration test utils to central package --- test/integration/epp/hermetic_test.go | 80 ++++--------------- .../request.go => test/integration/util.go | 54 ++++++++++++- 2 files changed, 69 insertions(+), 65 deletions(-) rename pkg/epp/util/testing/request.go => test/integration/util.go (57%) diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 2acdacf8..0ba0e14a 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -66,7 +66,8 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" - utiltesting "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" + epptestutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" + integrationutils "sigs.k8s.io/gateway-api-inference-extension/test/integration" "sigs.k8s.io/yaml" ) @@ -104,7 +105,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }{ { name: "select lower queue and kv cache, no active lora", - req: utiltesting.GenerateRequest(logger, "test1", "my-model"), + req: integrationutils.GenerateRequest(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ fakePod(0): { @@ -145,7 +146,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "select active lora, low queue", - req: utiltesting.GenerateRequest(logger, "test2", "sql-lora"), + req: integrationutils.GenerateRequest(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ @@ -199,7 +200,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "select no lora despite active model, avoid excessive queue size", - req: utiltesting.GenerateRequest(logger, "test3", "sql-lora"), + req: integrationutils.GenerateRequest(logger, "test3", "sql-lora"), // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 @@ -253,7 +254,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical and all models past threshold, shed request", - req: utiltesting.GenerateRequest(logger, "test4", "sql-lora-sheddable"), + req: integrationutils.GenerateRequest(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ @@ -296,7 +297,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical, but one server has capacity, do not shed", - req: utiltesting.GenerateRequest(logger, "test5", "sql-lora-sheddable"), + req: integrationutils.GenerateRequest(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ fakePod(0): { @@ -370,7 +371,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { }, DynamicMetadata: test.wantMetadata, } - res, err := sendRequest(t, client, test.req) + res, err := integrationutils.SendRequest(t, client, test.req) if err != nil && !test.wantErr { t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) @@ -410,7 +411,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // Request flow tests { name: "select lower queue and kv cache, no active lora", - requests: utiltesting.GenerateStreamedRequestSet(logger, "test1", "my-model"), + requests: integrationutils.GenerateStreamedRequestSet(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ fakePod(0): { @@ -484,7 +485,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, { name: "select active lora, low queue", - requests: utiltesting.GenerateStreamedRequestSet(logger, "test2", "sql-lora"), + requests: integrationutils.GenerateStreamedRequestSet(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ @@ -565,7 +566,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, { name: "select no lora despite active model, avoid excessive queue size", - requests: utiltesting.GenerateStreamedRequestSet(logger, "test3", "sql-lora"), + requests: integrationutils.GenerateStreamedRequestSet(logger, "test3", "sql-lora"), // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 @@ -646,7 +647,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical and all models past threshold, shed request", - requests: utiltesting.GenerateStreamedRequestSet(logger, "test4", "sql-lora-sheddable"), + requests: integrationutils.GenerateStreamedRequestSet(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ @@ -692,7 +693,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, { name: "noncritical, but one server has capacity, do not shed", - requests: utiltesting.GenerateStreamedRequestSet(logger, "test5", "sql-lora-sheddable"), + requests: integrationutils.GenerateStreamedRequestSet(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ fakePod(0): { @@ -1483,7 +1484,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { t.Run(test.name, func(t *testing.T) { client, cleanup := setUpHermeticServer(t, test.pods, true) t.Cleanup(cleanup) - responses, err := streamedRequest(t, client, test.requests, len(test.wantResponses)) + responses, err := integrationutils.StreamedRequest(t, client, test.requests, len(test.wantResponses)) if err != nil && !test.wantErr { t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) @@ -1522,7 +1523,7 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac } for pod := range podAndMetrics { - pod := utiltesting.MakePod(pod.NamespacedName.Name). + pod := epptestutil.MakePod(pod.NamespacedName.Name). Namespace(pod.NamespacedName.Namespace). ReadyCondition(). Labels(podLabels). @@ -1571,7 +1572,7 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac // clear created pods for pod := range podAndMetrics { - pod := utiltesting.MakePod(pod.NamespacedName.Name). + pod := epptestutil.MakePod(pod.NamespacedName.Name). Namespace(pod.NamespacedName.Namespace).Complete().ObjRef() if err := k8sClient.Delete(context.Background(), pod); err != nil { @@ -1688,55 +1689,6 @@ func BeforeSuite() func() { } } -func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - - res, err := client.Recv() - if err != nil { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - return res, err -} - -func streamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { - for _, req := range requests { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - } - responses := []*extProcPb.ProcessingResponse{} - - // Make an incredible simple timeout func in the case where - // there is less than the expected amount of responses; bail and fail. - var simpleTimeout bool - go func() { - time.Sleep(10 * time.Second) - simpleTimeout = true - }() - - for range expectedResponses { - if simpleTimeout { - break - } - res, err := client.Recv() - if err != nil && err != io.EOF { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - responses = append(responses, res) - } - return responses, nil -} - // readDocuments reads documents from file. func readDocuments(fp string) ([][]byte, error) { b, err := os.ReadFile(fp) diff --git a/pkg/epp/util/testing/request.go b/test/integration/util.go similarity index 57% rename from pkg/epp/util/testing/request.go rename to test/integration/util.go index 30772ad5..294317c3 100644 --- a/pkg/epp/util/testing/request.go +++ b/test/integration/util.go @@ -14,10 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package testing +package integration import ( "encoding/json" + "io" + "testing" + "time" envoyCorev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" @@ -25,6 +28,55 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +func SendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + + res, err := client.Recv() + if err != nil { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + return res, err +} + +func StreamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, requests []*extProcPb.ProcessingRequest, expectedResponses int) ([]*extProcPb.ProcessingResponse, error) { + for _, req := range requests { + t.Logf("Sending request: %v", req) + if err := client.Send(req); err != nil { + t.Logf("Failed to send request %+v: %v", req, err) + return nil, err + } + } + responses := []*extProcPb.ProcessingResponse{} + + // Make an incredible simple timeout func in the case where + // there is less than the expected amount of responses; bail and fail. + var simpleTimeout bool + go func() { + time.Sleep(10 * time.Second) + simpleTimeout = true + }() + + for range expectedResponses { + if simpleTimeout { + break + } + res, err := client.Recv() + if err != nil && err != io.EOF { + t.Logf("Failed to receive: %v", err) + return nil, err + } + t.Logf("Received request %+v", res) + responses = append(responses, res) + } + return responses, nil +} + func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.ProcessingRequest { j := map[string]interface{}{ "model": model, From a13a1239330ffaaafa4d0f948c00cafd106086aa Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Wed, 2 Apr 2025 00:34:41 -0400 Subject: [PATCH 190/260] BBR readme fixes (#640) --- config/charts/body-based-routing/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/charts/body-based-routing/README.md b/config/charts/body-based-routing/README.md index a6b8d3cd..d311b8c3 100644 --- a/config/charts/body-based-routing/README.md +++ b/config/charts/body-based-routing/README.md @@ -10,7 +10,7 @@ To install a body-based router named `body-based-router`, you can run the follow ```txt $ helm install body-based-router ./config/charts/body-based-routing \ --set provider.name=[gke|istio] \ - --set inference-gateway.name=inference-gateway + --set inferenceGateway.name=inference-gateway ``` Note that the provider name is needed to ensure provider-specific manifests are also applied. If no provider is specified, then only @@ -19,7 +19,7 @@ the deployment and service are deployed. To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: ```txt -$ helm install body-based-router oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/body-based-router \ +$ helm install body-based-router oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/body-based-routing \ --version v0 --set provider.name=[gke|istio] ``` @@ -51,4 +51,4 @@ The following table list the configurable parameters of the chart. ## Notes -This chart should only be deployed once per Gateway. \ No newline at end of file +This chart should only be deployed once per Gateway. From 0a0d609c003cb504fe93a03db6dc36bd8c91ba37 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Wed, 2 Apr 2025 11:50:47 -0400 Subject: [PATCH 191/260] Add streaming integration tests for BBR (#627) --- pkg/body-based-routing/handlers/server.go | 8 +- test/integration/bbr/hermetic_test.go | 210 +++++++++++++++++----- test/integration/util.go | 8 +- 3 files changed, 174 insertions(+), 52 deletions(-) diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/body-based-routing/handlers/server.go index 24664f98..484b3318 100644 --- a/pkg/body-based-routing/handlers/server.go +++ b/pkg/body-based-routing/handlers/server.go @@ -114,16 +114,16 @@ func (s *Server) processRequestBody(ctx context.Context, body *extProcPb.HttpBod var requestBody map[string]interface{} if s.streaming { + streamedBody.body = append(streamedBody.body, body.Body...) // In the stream case, we can receive multiple request bodies. - if !body.EndOfStream { - streamedBody.body = append(streamedBody.body, body.Body...) - return nil, nil - } else { + if body.EndOfStream { loggerVerbose.Info("Flushing stream buffer") err := json.Unmarshal(streamedBody.body, &requestBody) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") } + } else { + return nil, nil } } else { if err := json.Unmarshal(body.GetBody(), &requestBody); err != nil { diff --git a/test/integration/bbr/hermetic_test.go b/test/integration/bbr/hermetic_test.go index 718bfedf..02d412ab 100644 --- a/test/integration/bbr/hermetic_test.go +++ b/test/integration/bbr/hermetic_test.go @@ -19,20 +19,19 @@ package bbr import ( "context" - "encoding/json" "fmt" "testing" "time" configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + integrationutils "sigs.k8s.io/gateway-api-inference-extension/test/integration" ) var logger = logutil.NewTestLogger().V(logutil.VERBOSE) @@ -46,7 +45,7 @@ func TestBodyBasedRouting(t *testing.T) { }{ { name: "success adding model parameter to header", - req: generateRequest(logger, "llama"), + req: integrationutils.GenerateRequest(logger, "test", "llama"), wantHeaders: []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ @@ -59,7 +58,7 @@ func TestBodyBasedRouting(t *testing.T) { }, { name: "no model parameter", - req: generateRequest(logger, ""), + req: integrationutils.GenerateRequest(logger, "test1", ""), wantHeaders: []*configPb.HeaderValueOption{}, wantErr: false, }, @@ -67,7 +66,7 @@ func TestBodyBasedRouting(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer() + client, cleanup := setUpHermeticServer(false) t.Cleanup(cleanup) want := &extProcPb.ProcessingResponse{} @@ -88,7 +87,7 @@ func TestBodyBasedRouting(t *testing.T) { } } - res, err := sendRequest(t, client, test.req) + res, err := integrationutils.SendRequest(t, client, test.req) if err != nil && !test.wantErr { t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) } @@ -99,12 +98,171 @@ func TestBodyBasedRouting(t *testing.T) { } } -func setUpHermeticServer() (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +func TestFullDuplexStreamed_BodyBasedRouting(t *testing.T) { + tests := []struct { + name string + reqs []*extProcPb.ProcessingRequest + wantResponses []*extProcPb.ProcessingResponse + wantErr bool + }{ + { + name: "success adding model parameter to header", + reqs: integrationutils.GenerateStreamedRequestSet(logger, "test", "foo"), + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte("foo"), + }, + }, + }}, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"foo\",\"prompt\":\"test\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "success adding model parameter to header with multiple body chunks", + reqs: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "hi", + Value: "mom", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lo"), EndOfStream: false}, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("ra-sheddable\",\"prompt\":\"test\",\"temperature\":0}"), EndOfStream: true}, + }, + }, + }, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "X-Gateway-Model-Name", + RawValue: []byte("sql-lora-sheddable"), + }, + }, + }}, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-sheddable\",\"prompt\":\"test\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "no model parameter", + reqs: integrationutils.GenerateStreamedRequestSet(logger, "test", ""), + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{}, + }, + }, + { + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("{\"max_tokens\":100,\"prompt\":\"test\",\"temperature\":0}"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, cleanup := setUpHermeticServer(true) + t.Cleanup(cleanup) + + responses, err := integrationutils.StreamedRequest(t, client, test.reqs, len(test.wantResponses)) + if err != nil && !test.wantErr { + t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) + } + + if diff := cmp.Diff(test.wantResponses, responses, protocmp.Transform()); diff != "" { + t.Errorf("Unexpected response, (-want +got): %v", diff) + } + }) + } +} + +func setUpHermeticServer(streaming bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { port := 9004 serverCtx, stopServer := context.WithCancel(context.Background()) serverRunner := runserver.NewDefaultExtProcServerRunner(port, false) serverRunner.SecureServing = false + serverRunner.Streaming = streaming go func() { if err := serverRunner.AsRunnable(logger.WithName("ext-proc")).Start(serverCtx); err != nil { @@ -133,41 +291,3 @@ func setUpHermeticServer() (client extProcPb.ExternalProcessor_ProcessClient, cl time.Sleep(5 * time.Second) } } - -func generateRequest(logger logr.Logger, model string) *extProcPb.ProcessingRequest { - j := map[string]interface{}{ - "prompt": "test1", - "max_tokens": 100, - "temperature": 0, - } - if model != "" { - j["model"] = model - } - - llmReq, err := json.Marshal(j) - if err != nil { - logutil.Fatal(logger, err, "Failed to unmarshal LLM request") - } - req := &extProcPb.ProcessingRequest{ - Request: &extProcPb.ProcessingRequest_RequestBody{ - RequestBody: &extProcPb.HttpBody{Body: llmReq}, - }, - } - return req -} - -func sendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, req *extProcPb.ProcessingRequest) (*extProcPb.ProcessingResponse, error) { - t.Logf("Sending request: %v", req) - if err := client.Send(req); err != nil { - t.Logf("Failed to send request %+v: %v", req, err) - return nil, err - } - - res, err := client.Recv() - if err != nil { - t.Logf("Failed to receive: %v", err) - return nil, err - } - t.Logf("Received request %+v", res) - return res, err -} diff --git a/test/integration/util.go b/test/integration/util.go index 294317c3..5fcc9d18 100644 --- a/test/integration/util.go +++ b/test/integration/util.go @@ -40,7 +40,7 @@ func SendRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessClient, t.Logf("Failed to receive: %v", err) return nil, err } - t.Logf("Received request %+v", res) + t.Logf("Received response %+v", res) return res, err } @@ -71,7 +71,7 @@ func StreamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessCli t.Logf("Failed to receive: %v", err) return nil, err } - t.Logf("Received request %+v", res) + t.Logf("Received response %+v", res) responses = append(responses, res) } return responses, nil @@ -79,11 +79,13 @@ func StreamedRequest(t *testing.T, client extProcPb.ExternalProcessor_ProcessCli func GenerateRequest(logger logr.Logger, prompt, model string) *extProcPb.ProcessingRequest { j := map[string]interface{}{ - "model": model, "prompt": prompt, "max_tokens": 100, "temperature": 0, } + if model != "" { + j["model"] = model + } llmReq, err := json.Marshal(j) if err != nil { From 3b562f32c066c11c539b45af31ff75d7030290c3 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 2 Apr 2025 16:00:38 -0700 Subject: [PATCH 192/260] Adding 2 new reviewers to the reviewers alias (#644) --- OWNERS_ALIASES | 3 +++ 1 file changed, 3 insertions(+) diff --git a/OWNERS_ALIASES b/OWNERS_ALIASES index 6e8e0c5d..933fbe9c 100644 --- a/OWNERS_ALIASES +++ b/OWNERS_ALIASES @@ -11,6 +11,9 @@ aliases: gateway-api-inference-extension-reviewers: - liu-cong - robscott + - shaneutt + - nirrozenbaum + wg-serving-leads: - ArangoGutierrez From 2a48131d9aacafe84e7a1b9a2744e9b5d30e2886 Mon Sep 17 00:00:00 2001 From: Nicole Xin Date: Wed, 2 Apr 2025 20:50:39 -0700 Subject: [PATCH 193/260] Add initial implementer's guide (#635) * Add initial implementer's guide * Add line break to fix the list formatting * Add line break to fix the list formatting * Address code review comments * Fix formatting for conformance tests --- site-src/guides/implementers.md | 112 +++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/site-src/guides/implementers.md b/site-src/guides/implementers.md index 5d1c6267..7bfd536a 100644 --- a/site-src/guides/implementers.md +++ b/site-src/guides/implementers.md @@ -1,3 +1,113 @@ # Implementer's Guide -TODO \ No newline at end of file +This guide is intended for developers looking to implement support for the InferencePool custom resources within their Gateway API controller. It outlines how InferencePool fits into the existing resource model, discusses implementation options, explains how to interact with extensions, and provides guidance on testing. + +## InferencePool as a Gateway Backend +Before we dive into the implementation, let’s recap how an InferencePool works. + +Overview of API integration + +**InferencePool** represents a set of Inference-focused Pods and an extension that will be used to route to them. The InferencePool introduces a new type of backend within the Gateway API resource model. Instead of targeting Services, a Gateway can route traffic to an InferencePool. This InferencePool then becomes responsible for intelligent routing to the underlying model server pods based on the associated InferenceModel configurations. + +Here is an example of how to route traffic to an InferencePool using an HTTPRoute: +``` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: llm-route +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: inference-gateway + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: base-model + matches: + - path: + type: PathPrefix + value: / +``` + +Note that the `rules.backendRefs` describes which InferencePool should receive the forwarded traffic when the path matches the corresponding path prefix. This is very similar to how we configure a Gateway with an HTTPRoute that directs traffic to a Service (a way to select Pods and specify a port). By using the InferencePool, it provides an abstraction over a set of compute resources (model server pods), and allows the controller to implement specialized routing strategies for these inference workloads. + +## Building the Gateway controller +The general idea of implementing a Gateway controller supporting the InferencePool involves two major steps: + +1. Tracking the endpoints for InferencePool backends +2. Callout to an extension to make intelligent routing decisions + +### Endpoint Tracking +Consider a simple inference pool like this: +``` +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + name: vllm-llama3-8b-instruct +spec: + targetPortNumber: 8000 + selector: + app: vllm-llama3-8b-instruct + extensionRef: + name: vllm-llama3-8b-instruct-epp +``` + +There are mainly two options for how to treat the Inference Pool in your controller. + +**Option 1: Shadow Service Creation** + +If your Gateway controller already handles Service as a backend, you can choose to create a headless Service that mirrors the endpoints defined by the InferencePool, like this: + +``` +apiVersion: v1 +kind: Service +metadata: + name: vllm-llama3-8b-instruct-shadow-service +spec: + ports: + - port: 54321 + protocol: TCP + targetPort: 8000 + selector: + app: vllm-llama3-8b-instruct + type: ClusterIP + clusterIP: None +``` + +The gateway controller would then treat this shadow service just like any other backend service it routes traffic to. + +This approach likely allows you to leverage existing service discovery, healthcheck infrastructure, and load balancing mechanisms that your controller already supports. However, it does come with the overhead of managing additional Service objects, and hence may affect the overall latency of the reconciliation of the Gateways. + +**Option 2: Tracking InferencePool Endpoints Separately** + +You can also choose to directly select and monitor the endpoints belonging to the InferencePool. For the simple inference pool example we have above, the controller would use the label `app: vllm-llama3-8b-instruct` to discover the pods matching the criteria, and get their endpoints (i.e. IP and port number). It would then need to monitor these pods for health and availability. + +With this approach, you can tailor the endpoint tracking and routing logic specifically to the characteristics and requirements of your InferencePool. + +### Callout Extension + +The [Endpoint Picker](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp), or EPP, is a core component of the inference extension. The primary interaction for routing requests is defined between the proxy (e.g., Envoy) and the EPP using the Envoy [external processing service protocol](https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor.proto). See the [Endpoint Picker Protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/004-endpoint-picker-protocol) for more information. + +#### How to Callout to EPP + +For each HTTP request, the proxy CAN communicate the subset of endpoints the EPP MUST pick from by setting `x-gateway-destination-endpoint-subset` key in the filter metadata field of the ext-proc request. If this key is set, the EPP must select from this endpoint list. If the list is empty or no endpoints are eligible, it should return a 503 error. If the key isn't set, the EPP selects from the endpoints defined by the InferencePool selector. + +#### Response from the extension + +The EPP communicates the chosen endpoint to the proxy via the `x-gateway-destination-endpoint` HTTP header and the `dynamic_metadata` field of the ext-proc response. Failure to communicate the endpoint using both methods results in a 503 error if no endpoints are ready, or a 429 error if the request should be dropped. The header and metadata values must match. In addition to the chosen endpoint, a single fallback endpoint CAN be set using the key `x-gateway-destination-endpoint-fallback` in the same metadata namespace as one used for `x-gateway-destination-endpoint`. + +## Testing Tips + +Here are some tips for testing your controller end-to-end: + +- **Focus on Key Scenarios**: Add common scenarios like creating, updating, and deleting InferencePool resources, as well as different routing rules that target InferencePool backends. +- **Verify Routing Behaviors**: Design more complex routing scenarios and verify that requests are correctly routed to the appropriate model server pods within the InferencePool based on the InferenceModel configuration. +- **Test Error Handling**: Verify that the controller correctly handles scenarios like unsupported model names or resource constraints (if criticality-based shedding is implemented). Test with state transitions (such as constant requests while Pods behind EPP are being replaced and Pods behind InferencePool are being replaced) to ensure that the system is resilient to failures and can automatically recover by redirecting traffic to healthy Pods. +- **Using Reference EPP Implementation + Echoserver**: You can use the [reference EPP implementation](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp) for testing your controller end-to-end. Instead of a full-fledged model server, a simple mock server (like the [echoserver](https://github.com/kubernetes-sigs/ingress-controller-conformance/tree/master/images/echoserver)) can be very useful for verifying routing to ensure the correct pod received the request. +- **Performance Test**: Run end-to-end [benchmarks](https://gateway-api-inference-extension.sigs.k8s.io/performance/benchmark/) to make sure that your inference gateway can achieve the latency target that is desired. + +### Conformance Tests + +A set of conformance tests will be developed soon to help verify that a controller is working as expected. This guide will be updated once we have more information. Stay tuned! From 206ef937d2e693a7c05b2892f69dbb9a5e3dbf79 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 3 Apr 2025 00:30:36 -0400 Subject: [PATCH 194/260] Update BBR istio.yaml to use FULL_DUPLEX_STREAM (#629) --- config/charts/body-based-routing/templates/istio.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/charts/body-based-routing/templates/istio.yaml b/config/charts/body-based-routing/templates/istio.yaml index c4c1444f..6d4535cc 100644 --- a/config/charts/body-based-routing/templates/istio.yaml +++ b/config/charts/body-based-routing/templates/istio.yaml @@ -25,9 +25,9 @@ spec: processing_mode: request_header_mode: "SEND" response_header_mode: "SKIP" - request_body_mode: "BUFFERED" + request_body_mode: "FULL_DUPLEX_STREAMED" response_body_mode: "NONE" - request_trailer_mode: "SKIP" + request_trailer_mode: "SEND" response_trailer_mode: "SKIP" grpc_service: envoy_grpc: From 2e4642563a907d5ab241866c2f56c37b161b79d7 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Thu, 3 Apr 2025 09:30:38 -0700 Subject: [PATCH 195/260] Bumps Kgateway to v2.0.0 (#646) Signed-off-by: Daneyon Hansen --- site-src/guides/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index f1545438..367ca902 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -201,7 +201,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv 2. Set the Kgateway version and install the Kgateway CRDs. ```bash - KGTW_VERSION=v2.0.0-rc.2 + KGTW_VERSION=v2.0.0 helm upgrade -i --create-namespace --namespace kgateway-system --version $KGTW_VERSION kgateway-crds oci://cr.kgateway.dev/kgateway-dev/charts/kgateway-crds ``` From 81100ffe0e9180d608fd4ba91b514e7d40c290cd Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 3 Apr 2025 22:02:37 +0300 Subject: [PATCH 196/260] remove deprecated v1alpha2.AddToScheme and use v1alpha2.Install instead (#649) Signed-off-by: Nir Rozenbaum --- pkg/epp/controller/inferencemodel_reconciler_test.go | 2 +- pkg/epp/controller/inferencepool_reconciler_test.go | 2 +- pkg/epp/server/controller_manager.go | 2 +- test/integration/epp/hermetic_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index cd1ff1fb..57dc2469 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -178,7 +178,7 @@ func TestInferenceModelReconciler(t *testing.T) { t.Run(test.name, func(t *testing.T) { // Create a fake client with no InferenceModel objects. scheme := runtime.NewScheme() - _ = v1alpha2.AddToScheme(scheme) + _ = v1alpha2.Install(scheme) initObjs := []client.Object{} if test.model != nil { initObjs = append(initObjs, test.model) diff --git a/pkg/epp/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go index 27c4238e..7e5d4801 100644 --- a/pkg/epp/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -77,7 +77,7 @@ func TestInferencePoolReconciler(t *testing.T) { // Set up the scheme. scheme := runtime.NewScheme() _ = clientgoscheme.AddToScheme(scheme) - _ = v1alpha2.AddToScheme(scheme) + _ = v1alpha2.Install(scheme) // Create a fake client with the pool and the pods. initialObjects := []client.Object{pool1, pool2} diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index 41fe86a9..aaad8976 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -36,7 +36,7 @@ var scheme = runtime.NewScheme() func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha2.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.Install(scheme)) } // DefaultManagerOptions returns the default options used to create the manager. diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 0ba0e14a..cf00a049 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -1602,7 +1602,7 @@ func BeforeSuite() func() { } utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(v1alpha2.AddToScheme(scheme)) + utilruntime.Must(v1alpha2.Install(scheme)) k8sClient, err = k8sclient.New(cfg, k8sclient.Options{Scheme: scheme}) if err != nil { From 2759e3f06fc65235edc131e4d9d191dd71d69b2a Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Sat, 5 Apr 2025 02:34:37 +0300 Subject: [PATCH 197/260] removed time.sleep and using ticker instead (#648) * removed time.sleep and using ticker instead Signed-off-by: Nir Rozenbaum * move ticker creation outside of go routine. make sure refresh internal is valid at the ticker creation time Signed-off-by: Nir Rozenbaum * add DefaultRefreshPrometheusMetricsInterval for test purposes. once ticker was introduced instead of sleep, having 0 as the refresh internal is not valid. Signed-off-by: Nir Rozenbaum * wait in test until metrics are available before running tests that rely on the values. up until now, the metrics go routine ran in tests with time.Sleep(0), which means metrics were avaiable immediately. while in tests in might be acceptable to wait few seconds using sleep, in the actual code (not tests) it's a bad practice to use sleep which was replaced with a ticker (to perform periodic task in an endless loop). Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/backend/metrics/logger.go | 13 +++++++------ pkg/epp/backend/metrics/pod_metrics.go | 14 ++++++-------- pkg/epp/server/runserver.go | 1 + test/integration/epp/hermetic_test.go | 2 ++ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/pkg/epp/backend/metrics/logger.go b/pkg/epp/backend/metrics/logger.go index d71dc3fa..d9a93027 100644 --- a/pkg/epp/backend/metrics/logger.go +++ b/pkg/epp/backend/metrics/logger.go @@ -32,6 +32,7 @@ const ( // Note currently the EPP treats stale metrics same as fresh. // TODO: https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/336 metricsValidityPeriod = 5 * time.Second + debugPrintInterval = 5 * time.Second ) type Datastore interface { @@ -46,16 +47,15 @@ type Datastore interface { // enabled; 2) flushes Prometheus metrics about the backend servers. func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometheusMetricsInterval time.Duration) { logger := log.FromContext(ctx) - - // Periodically flush prometheus metrics for inference pool + ticker := time.NewTicker(refreshPrometheusMetricsInterval) go func() { + defer ticker.Stop() for { select { case <-ctx.Done(): logger.V(logutil.DEFAULT).Info("Shutting down prometheus metrics thread") return - default: - time.Sleep(refreshPrometheusMetricsInterval) + case <-ticker.C: // Periodically flush prometheus metrics for inference pool flushPrometheusMetricsOnce(logger, datastore) } } @@ -64,13 +64,14 @@ func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometh // Periodically print out the pods and metrics for DEBUGGING. if logger := logger.V(logutil.DEBUG); logger.Enabled() { go func() { + ticker := time.NewTicker(debugPrintInterval) + defer ticker.Stop() for { select { case <-ctx.Done(): logger.V(logutil.DEFAULT).Info("Shutting down metrics logger thread") return - default: - time.Sleep(5 * time.Second) + case <-ticker.C: podsWithFreshMetrics := datastore.PodList(func(pm PodMetrics) bool { return time.Since(pm.GetMetrics().UpdateTime) <= metricsValidityPeriod }) diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index cfb6b138..c85d4d79 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -84,21 +84,19 @@ func (pm *podMetrics) startRefreshLoop() { pm.once.Do(func() { go func() { pm.logger.V(logutil.DEFAULT).Info("Starting refresher", "pod", pm.GetPod()) + ticker := time.NewTicker(pm.interval) + defer ticker.Stop() for { select { case <-pm.done: return case <-pm.parentCtx.Done(): return - default: + case <-ticker.C: // refresh metrics periodically + if err := pm.refreshMetrics(); err != nil { + pm.logger.V(logutil.TRACE).Error(err, "Failed to refresh metrics", "pod", pm.GetPod()) + } } - - err := pm.refreshMetrics() - if err != nil { - pm.logger.V(logutil.TRACE).Error(err, "Failed to refresh metrics", "pod", pm.GetPod()) - } - - time.Sleep(pm.interval) } }() }) diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index a6c9f1d3..7ed183be 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -76,6 +76,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { PoolName: DefaultPoolName, PoolNamespace: DefaultPoolNamespace, SecureServing: DefaultSecureServing, + RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, // Datastore can be assigned later. } } diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index cf00a049..1c5eca18 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -1548,6 +1548,8 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac } }() + time.Sleep(serverRunner.RefreshPrometheusMetricsInterval) // wait for metrics to get available before running tests that rely on these metrics + // check if all pods are synced to datastore assert.EventuallyWithT(t, func(t *assert.CollectT) { assert.Len(t, serverRunner.Datastore.PodGetAll(), len(podAndMetrics), "Datastore not synced") From 6d7655b17755f6ea191ca53d542f699d6fb79ebb Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 7 Apr 2025 01:26:38 +0300 Subject: [PATCH 198/260] update release version in README (#653) Signed-off-by: Nir Rozenbaum --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ff00581..b74a13e9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It currently requires a version of vLLM that supports the necessary metrics to p ## Status -This project is [alpha (0.2 release)](https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/tag/v0.2.0). It should not be used in production yet. +This project is [alpha (0.3 release)](https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/tag/v0.3.0). It should not be used in production yet. ## Getting Started From 2c0a637826218ab0bda3a1f2e4d43e897344315e Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 7 Apr 2025 02:44:38 +0300 Subject: [PATCH 199/260] fix some issues in e2e tests (#621) * added timeout to curl command which may otherwise hang Signed-off-by: Nir Rozenbaum * check HF_TOKEN set at the beginning of the test Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- test/e2e/epp/e2e_suite_test.go | 30 ++++++++++++++++++------------ test/e2e/epp/e2e_test.go | 8 ++++++-- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index 643bbf75..61ee2540 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -49,6 +49,8 @@ const ( defaultReadyTimeout = 3 * time.Minute // defaultModelReadyTimeout is the default timeout for the model server deployment to report a ready state. defaultModelReadyTimeout = 10 * time.Minute + // defaultCurlTimeout is the default timeout for the curl command to get a response. + defaultCurlTimeout = 30 * time.Second // defaultInterval is the default interval to check if a resource exists or ready conditions. defaultInterval = time.Millisecond * 250 // defaultCurlInterval is the default interval to run the test curl command. @@ -107,7 +109,11 @@ var _ = ginkgo.BeforeSuite(func() { }) func setupInfra() { - modelServerManifest := readModelServerManifestPath() + modelServerManifestPath := readModelServerManifestPath() + modelServerManifestArray := getYamlsFromModelServerManifest(modelServerManifestPath) + if strings.Contains(modelServerManifestArray[0], "hf-token") { + createHfSecret(cli, modelServerSecretManifest) + } crds := map[string]string{ "inferencepools.inference.networking.x-k8s.io": inferPoolManifest, "inferencemodels.inference.networking.x-k8s.io": inferModelManifest, @@ -117,7 +123,7 @@ func setupInfra() { createClient(cli, clientManifest) createEnvoy(cli, envoyManifest) // Run this step last, as it requires additional time for the model server to become ready. - createModelServer(cli, modelServerSecretManifest, modelServerManifest) + createModelServer(cli, modelServerManifestArray, modelServerManifestPath) } var _ = ginkgo.AfterSuite(func() { @@ -137,7 +143,7 @@ func setupSuite() { err = apiextv1.AddToScheme(scheme) gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) - err = infextv1a2.AddToScheme(scheme) + err = infextv1a2.Install(scheme) gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) cli, err = client.New(cfg, client.Options{Scheme: scheme}) @@ -171,6 +177,7 @@ var ( existsTimeout = getTimeout("EXISTS_TIMEOUT", defaultExistsTimeout) readyTimeout = getTimeout("READY_TIMEOUT", defaultReadyTimeout) modelReadyTimeout = getTimeout("MODEL_READY_TIMEOUT", defaultModelReadyTimeout) + curlTimeout = getTimeout("CURL_TIMEOUT", defaultCurlTimeout) interval = defaultInterval curlInterval = defaultCurlInterval ) @@ -191,6 +198,13 @@ func readModelServerManifestPath() string { return modelServerManifestFilepath } +func getYamlsFromModelServerManifest(modelServerManifestPath string) []string { + ginkgo.By("Ensuring the model server manifest points to an existing file") + modelServerManifestArray := readYaml(modelServerManifestPath) + gomega.Expect(modelServerManifestArray).NotTo(gomega.BeEmpty()) + return modelServerManifestArray +} + // createCRDs creates the Inference Extension CRDs used for testing. func createCRDs(k8sClient client.Client, crds map[string]string) { for name, path := range crds { @@ -224,15 +238,7 @@ func createClient(k8sClient client.Client, filePath string) { } // createModelServer creates the model server resources used for testing from the given filePaths. -func createModelServer(k8sClient client.Client, secretPath, deployPath string) { - ginkgo.By("Ensuring the model server manifest points to an existing file") - modelServerManifestArray := readYaml(deployPath) - gomega.Expect(modelServerManifestArray).NotTo(gomega.BeEmpty()) - modelServerManifestYaml := modelServerManifestArray[0] - if strings.Contains(modelServerManifestYaml, "hf-token") { - createHfSecret(k8sClient, secretPath) - } - +func createModelServer(k8sClient client.Client, modelServerManifestArray []string, deployPath string) { ginkgo.By("Creating model server resources from manifest: " + deployPath) createObjsFromYaml(k8sClient, modelServerManifestArray) diff --git a/test/e2e/epp/e2e_test.go b/test/e2e/epp/e2e_test.go index e86b2d49..7240cebc 100644 --- a/test/e2e/epp/e2e_test.go +++ b/test/e2e/epp/e2e_test.go @@ -18,7 +18,9 @@ package epp import ( "fmt" + "strconv" "strings" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -53,7 +55,7 @@ var _ = ginkgo.Describe("InferencePool", func() { }, existsTimeout, interval).Should(gomega.Succeed()) ginkgo.By("Verifying connectivity through the inference extension") - curlCmd := getCurlCommand(envoyName, nsName, envoyPort, modelName) + curlCmd := getCurlCommand(envoyName, nsName, envoyPort, modelName, curlTimeout) // Ensure the expected responses include the inferencemodel target model names. var expected []string @@ -112,10 +114,12 @@ func newInferenceModel(ns string) *v1alpha2.InferenceModel { // getCurlCommand returns the command, as a slice of strings, for curl'ing // the test model server at the given name, namespace, port, and model name. -func getCurlCommand(name, ns, port, model string) []string { +func getCurlCommand(name, ns, port, model string, timeout time.Duration) []string { return []string{ "curl", "-i", + "--max-time", + strconv.Itoa((int)(timeout.Seconds())), fmt.Sprintf("%s.%s.svc:%s/v1/completions", name, ns, port), "-H", "Content-Type: application/json", From 264ee45a447949e4db0178ade98479b060dbc2b5 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 7 Apr 2025 04:48:41 -0700 Subject: [PATCH 200/260] Refactor scheduler (#645) --- pkg/epp/backend/metrics/metrics.go | 3 +- pkg/epp/backend/metrics/metrics_test.go | 11 +- pkg/epp/backend/metrics/pod_metrics_test.go | 2 + pkg/epp/backend/metrics/types.go | 26 +- pkg/epp/datastore/datastore_test.go | 3 + pkg/epp/handlers/request.go | 4 +- pkg/epp/handlers/server.go | 5 +- pkg/epp/handlers/streamingserver.go | 4 +- pkg/epp/scheduling/filter.go | 151 +++++---- pkg/epp/scheduling/filter_test.go | 326 +++----------------- pkg/epp/scheduling/scheduler.go | 121 ++++---- pkg/epp/scheduling/scheduler_test.go | 232 ++++++++++++++ pkg/epp/scheduling/types.go | 27 -- pkg/epp/scheduling/types/types.go | 88 ++++++ test/integration/epp/hermetic_test.go | 39 ++- 15 files changed, 592 insertions(+), 450 deletions(-) create mode 100644 pkg/epp/scheduling/scheduler_test.go delete mode 100644 pkg/epp/scheduling/types.go create mode 100644 pkg/epp/scheduling/types/types.go diff --git a/pkg/epp/backend/metrics/metrics.go b/pkg/epp/backend/metrics/metrics.go index d48b1dc5..96814b4b 100644 --- a/pkg/epp/backend/metrics/metrics.go +++ b/pkg/epp/backend/metrics/metrics.go @@ -109,6 +109,7 @@ func (p *PodMetricsClientImpl) promToPodMetrics( if loraMetrics != nil { updated.ActiveModels = make(map[string]int) + updated.WaitingModels = make(map[string]int) for _, label := range loraMetrics.GetLabel() { if label.GetName() == LoraInfoRunningAdaptersMetricName { if label.GetValue() != "" { @@ -122,7 +123,7 @@ func (p *PodMetricsClientImpl) promToPodMetrics( if label.GetValue() != "" { adapterList := strings.Split(label.GetValue(), ",") for _, adapter := range adapterList { - updated.ActiveModels[adapter] = 0 + updated.WaitingModels[adapter] = 0 } } } diff --git a/pkg/epp/backend/metrics/metrics_test.go b/pkg/epp/backend/metrics/metrics_test.go index d0396bf7..e3b45b94 100644 --- a/pkg/epp/backend/metrics/metrics_test.go +++ b/pkg/epp/backend/metrics/metrics_test.go @@ -404,7 +404,8 @@ func TestPromToPodMetrics(t *testing.T) { expectedMetrics: &Metrics{ WaitingQueueSize: 7, KVCacheUsagePercent: 0.8, - ActiveModels: map[string]int{"lora1": 0, "lora2": 0, "lora3": 0}, + ActiveModels: map[string]int{"lora1": 0, "lora2": 0}, + WaitingModels: map[string]int{"lora3": 0}, MaxActiveModels: 3, }, }, @@ -416,8 +417,8 @@ func TestPromToPodMetrics(t *testing.T) { KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, }, - existingMetrics: &Metrics{ActiveModels: map[string]int{}}, - expectedMetrics: &Metrics{ActiveModels: map[string]int{}}, + existingMetrics: &Metrics{ActiveModels: map[string]int{}, WaitingModels: map[string]int{}}, + expectedMetrics: &Metrics{ActiveModels: map[string]int{}, WaitingModels: map[string]int{}}, expectedErr: multierr.Combine(errors.New("metric family \"vllm_waiting\" not found"), errors.New("metric family \"vllm_usage\" not found"), errors.New("metric family \"vllm:lora_requests_info\" not found")), }, { @@ -439,7 +440,8 @@ func TestPromToPodMetrics(t *testing.T) { expectedMetrics: &Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.8, - ActiveModels: map[string]int{"lora1": 0, "lora2": 0, "lora3": 0}, + ActiveModels: map[string]int{"lora1": 0, "lora2": 0}, + WaitingModels: map[string]int{"lora3": 0}, MaxActiveModels: 3, }, expectedErr: errors.New("metric family \"vllm_waiting\" not found"), @@ -457,6 +459,7 @@ func TestPromToPodMetrics(t *testing.T) { existingMetrics: &Metrics{}, expectedMetrics: &Metrics{ ActiveModels: map[string]int{"lora1": 0}, + WaitingModels: map[string]int{}, MaxActiveModels: 0, // Should still default to 0. }, diff --git a/pkg/epp/backend/metrics/pod_metrics_test.go b/pkg/epp/backend/metrics/pod_metrics_test.go index cf6698ca..e79c1bf0 100644 --- a/pkg/epp/backend/metrics/pod_metrics_test.go +++ b/pkg/epp/backend/metrics/pod_metrics_test.go @@ -44,6 +44,7 @@ var ( "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, } updated = &Metrics{ WaitingQueueSize: 9999, @@ -53,6 +54,7 @@ var ( "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, } ) diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index 17db23b4..925a0cc5 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -41,6 +41,7 @@ type PodMetricsFactory struct { } func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1.Pod, ds Datastore) PodMetrics { + pod := toInternalPod(in) pm := &podMetrics{ pmc: f.pmc, ds: ds, @@ -48,9 +49,9 @@ func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1. parentCtx: parentCtx, once: sync.Once{}, done: make(chan struct{}), - logger: log.FromContext(parentCtx), + logger: log.FromContext(parentCtx).WithValues("pod", pod.NamespacedName), } - pm.pod.Store(toInternalPod(in)) + pm.pod.Store(pod) pm.metrics.Store(newMetrics()) pm.startRefreshLoop() @@ -77,9 +78,20 @@ func (p *Pod) String() string { return fmt.Sprintf("%+v", *p) } +func (p *Pod) Clone() *Pod { + return &Pod{ + NamespacedName: types.NamespacedName{ + Name: p.NamespacedName.Name, + Namespace: p.NamespacedName.Namespace, + }, + Address: p.Address, + } +} + type Metrics struct { // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. - ActiveModels map[string]int + ActiveModels map[string]int + WaitingModels map[string]int // MaxActiveModels is the maximum number of models that can be loaded to GPU. MaxActiveModels int RunningQueueSize int @@ -93,7 +105,8 @@ type Metrics struct { func newMetrics() *Metrics { return &Metrics{ - ActiveModels: make(map[string]int), + ActiveModels: make(map[string]int), + WaitingModels: make(map[string]int), } } @@ -109,8 +122,13 @@ func (m *Metrics) Clone() *Metrics { for k, v := range m.ActiveModels { cm[k] = v } + wm := make(map[string]int, len(m.WaitingModels)) + for k, v := range m.WaitingModels { + wm[k] = v + } clone := &Metrics{ ActiveModels: cm, + WaitingModels: wm, MaxActiveModels: m.MaxActiveModels, RunningQueueSize: m.RunningQueueSize, WaitingQueueSize: m.WaitingQueueSize, diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index 22bb0365..abbff429 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -236,6 +236,7 @@ var ( "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, } pod2 = &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -250,6 +251,7 @@ var ( "foo1": 1, "bar1": 1, }, + WaitingModels: map[string]int{}, } pod1NamespacedName = types.NamespacedName{Name: pod1.Name, Namespace: pod1.Namespace} pod2NamespacedName = types.NamespacedName{Name: pod2.Name, Namespace: pod2.Namespace} @@ -305,6 +307,7 @@ func TestMetrics(t *testing.T) { // Failed to fetch pod2 metrics so it remains the default values. { ActiveModels: map[string]int{}, + WaitingModels: map[string]int{}, WaitingQueueSize: 0, KVCacheUsagePercent: 0, MaxActiveModels: 0, diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index d7678fad..b786a15d 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -27,7 +27,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -74,7 +74,7 @@ func (s *Server) HandleRequestBody( return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } } - llmReq := &scheduling.LLMRequest{ + llmReq := &schedulingtypes.LLMRequest{ Model: model, ResolvedTargetModel: modelName, Critical: datastore.IsCritical(modelObj), diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index a92f091c..f6f375dd 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -26,10 +26,9 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "sigs.k8s.io/controller-runtime/pkg/log" - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -57,7 +56,7 @@ type Server struct { } type Scheduler interface { - Schedule(ctx context.Context, b *scheduling.LLMRequest) (targetPod backendmetrics.PodMetrics, err error) + Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (targetPod schedulingtypes.Pod, err error) } func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 874dd734..0e9020d8 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -37,7 +37,7 @@ import ( backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -343,7 +343,7 @@ func (s *StreamingServer) HandleRequestBody( return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } } - llmReq := &scheduling.LLMRequest{ + llmReq := &schedulingtypes.LLMRequest{ Model: model, ResolvedTargetModel: modelName, Critical: datastore.IsCritical(modelObj), diff --git a/pkg/epp/scheduling/filter.go b/pkg/epp/scheduling/filter.go index f4848089..99044e97 100644 --- a/pkg/epp/scheduling/filter.go +++ b/pkg/epp/scheduling/filter.go @@ -22,48 +22,63 @@ import ( "math/rand" "time" - "github.com/go-logr/logr" - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) type Filter interface { Name() string - Filter(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) + Filter(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) } -// filter applies current filterFunc, and then recursively applies next filters depending success or -// failure of the current filterFunc. -// It can be used to construct a flow chart algorithm. -type filter struct { +type basicFilter struct { name string filter filterFunc +} + +func (bf *basicFilter) Name() string { + if bf == nil { + return "nil" + } + return bf.name +} + +func (bf *basicFilter) Filter(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { + loggerTrace := ctx.Logger.V(logutil.TRACE) + loggerTrace.Info("Running a filter", "name", bf.Name(), "podCount", len(pods)) + + return bf.filter(ctx, pods) +} + +// decisionTreeFilter applies current filterFunc, and then recursively applies next filters +// depending success or failure of the current filter. +// It can be used to construct a flow chart algorithm. +type decisionTreeFilter struct { + current Filter // nextOnSuccess filter will be applied after successfully applying the current filter. // The filtered results will be passed to the next filter. - nextOnSuccess *filter + nextOnSuccess Filter // nextOnFailure filter will be applied if current filter fails. // The original input will be passed to the next filter. - nextOnFailure *filter + nextOnFailure Filter // nextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the // success or failure of the current filter. // NOTE: When using nextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. // However if that's not the case, nextOnSuccess and nextOnFailure will be used, instead of // nextOnSuccessOrFailure, in the success and failure scenarios, respectively. - nextOnSuccessOrFailure *filter + nextOnSuccessOrFailure Filter } -func (f *filter) Name() string { +func (f *decisionTreeFilter) Name() string { if f == nil { return "nil" } - return f.name + return f.current.Name() } -func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { - loggerTrace := logger.V(logutil.TRACE) - loggerTrace.Info("Running a filter", "name", f.Name(), "podCount", len(pods)) - - filtered, err := f.filter(logger, req, pods) +func (f *decisionTreeFilter) Filter(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { + loggerTrace := ctx.Logger.V(logutil.TRACE) + filtered, err := f.current.Filter(ctx, pods) next := f.nextOnSuccessOrFailure if err == nil && len(filtered) > 0 { @@ -76,7 +91,7 @@ func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []backendmetri } loggerTrace.Info("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filtered)) // On success, pass the filtered result to the next filter. - return next.Filter(logger, req, filtered) + return next.Filter(ctx, filtered) } else { if f.nextOnFailure == nil && f.nextOnSuccessOrFailure == nil { // No succeeding filters to run, return. @@ -87,19 +102,19 @@ func (f *filter) Filter(logger logr.Logger, req *LLMRequest, pods []backendmetri } loggerTrace.Info("Filter failed", "filter", f.Name(), "next", next.Name()) // On failure, pass the initial set of pods to the next filter. - return next.Filter(logger, req, pods) + return next.Filter(ctx, pods) } } // filterFunc filters a set of input pods to a subset. -type filterFunc func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) +type filterFunc func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) // toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. func toFilterFunc(pp podPredicate) filterFunc { - return func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { - filtered := []backendmetrics.PodMetrics{} + return func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { + filtered := []*types.PodMetrics{} for _, pod := range pods { - pass := pp(req, pod) + pass := pp(ctx.Req, pod) if pass { filtered = append(filtered, pod) } @@ -111,6 +126,11 @@ func toFilterFunc(pp podPredicate) filterFunc { } } +var leastQueueFilter = &basicFilter{ + name: "least queuing", + filter: leastQueuingFilterFunc, +} + // leastQueuingFilterFunc finds the max and min queue size of all pods, divides the whole range // (max-min) by the number of pods, and finds the pods that fall into the first range. // The intuition is that if there are multiple pods that share similar queue size in the low range, @@ -118,30 +138,36 @@ func toFilterFunc(pp podPredicate) filterFunc { // the least one as it gives more choices for the next filter, which on aggregate gave better // results. // TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { +func leastQueuingFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { min := math.MaxInt max := 0 - filtered := []backendmetrics.PodMetrics{} + filtered := []*types.PodMetrics{} for _, pod := range pods { - if pod.GetMetrics().WaitingQueueSize <= min { - min = pod.GetMetrics().WaitingQueueSize + if pod.WaitingQueueSize <= min { + min = pod.WaitingQueueSize } - if pod.GetMetrics().WaitingQueueSize >= max { - max = pod.GetMetrics().WaitingQueueSize + if pod.WaitingQueueSize >= max { + max = pod.WaitingQueueSize } } for _, pod := range pods { - if pod.GetMetrics().WaitingQueueSize >= min && pod.GetMetrics().WaitingQueueSize <= min+(max-min)/len(pods) { + if pod.WaitingQueueSize >= min && pod.WaitingQueueSize <= min+(max-min)/len(pods) { filtered = append(filtered, pod) } } return filtered, nil } -func lowQueueingPodPredicate(_ *LLMRequest, pod backendmetrics.PodMetrics) bool { - return pod.GetMetrics().WaitingQueueSize < config.QueueingThresholdLoRA +var lowQueueFilter = &basicFilter{ + name: "low queueing filter", + filter: toFilterFunc((queueThresholdPredicate(config.QueueingThresholdLoRA))), +} + +var leastKVCacheFilter = &basicFilter{ + name: "least KV cache percent", + filter: leastKVCacheFilterFunc, } // leastKVCacheFilterFunc finds the max and min KV cache of all pods, divides the whole range @@ -150,39 +176,31 @@ func lowQueueingPodPredicate(_ *LLMRequest, pod backendmetrics.PodMetrics) bool // should consider them all instead of the absolute minimum one. This worked better than picking the // least one as it gives more choices for the next filter, which on aggregate gave better results. // TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { +func leastKVCacheFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { min := math.MaxFloat64 var max float64 = 0 - filtered := []backendmetrics.PodMetrics{} + filtered := []*types.PodMetrics{} for _, pod := range pods { - if pod.GetMetrics().KVCacheUsagePercent <= min { - min = pod.GetMetrics().KVCacheUsagePercent + if pod.KVCacheUsagePercent <= min { + min = pod.KVCacheUsagePercent } - if pod.GetMetrics().KVCacheUsagePercent >= max { - max = pod.GetMetrics().KVCacheUsagePercent + if pod.KVCacheUsagePercent >= max { + max = pod.KVCacheUsagePercent } } for _, pod := range pods { - if pod.GetMetrics().KVCacheUsagePercent >= min && pod.GetMetrics().KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { + if pod.KVCacheUsagePercent >= min && pod.KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { filtered = append(filtered, pod) } } return filtered, nil } -// podPredicate is a filter function to check whether a pod is desired. -type podPredicate func(req *LLMRequest, pod backendmetrics.PodMetrics) bool - -// We consider serving an adapter low cost it the adapter is active in the model server, or the -// model server has room to load the adapter. The lowLoRACostPredicate ensures weak affinity by -// spreading the load of a LoRA adapter across multiple pods, avoiding "pinning" all requests to -// a single pod. This gave good performance in our initial benchmarking results in the scenario -// where # of lora slots > # of lora adapters. -func lowLoRACostPredicate(req *LLMRequest, pod backendmetrics.PodMetrics) bool { - _, ok := pod.GetMetrics().ActiveModels[req.ResolvedTargetModel] - return ok || len(pod.GetMetrics().ActiveModels) < pod.GetMetrics().MaxActiveModels +var loRAAffinityFilter = &basicFilter{ + name: "affinity LoRA", + filter: loRASoftAffinityFilterFunc, } // loRASoftAffinityPredicate implements a pod selection strategy that prioritizes pods @@ -201,18 +219,20 @@ func lowLoRACostPredicate(req *LLMRequest, pod backendmetrics.PodMetrics) bool { // Returns: // - Filtered slice of pod metrics based on affinity and availability // - Error if any issues occur during filtering -func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { +func loRASoftAffinityFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { // Pre-allocate slices with estimated capacity - filtered_affinity := make([]backendmetrics.PodMetrics, 0, len(pods)) - filtered_available := make([]backendmetrics.PodMetrics, 0, len(pods)) + filtered_affinity := make([]*types.PodMetrics, 0, len(pods)) + filtered_available := make([]*types.PodMetrics, 0, len(pods)) // Categorize pods based on affinity and availability for _, pod := range pods { + _, active := pod.ActiveModels[ctx.Req.ResolvedTargetModel] + _, waiting := pod.WaitingModels[ctx.Req.ResolvedTargetModel] - if _, exists := pod.GetMetrics().ActiveModels[req.ResolvedTargetModel]; exists { + if active || waiting { filtered_affinity = append(filtered_affinity, pod) - } else if len(pod.GetMetrics().ActiveModels) < pod.GetMetrics().MaxActiveModels { + } else if len(pod.ActiveModels)+len(pod.WaitingModels) < pod.MaxActiveModels { filtered_available = append(filtered_available, pod) } } @@ -237,12 +257,23 @@ func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []backendm return filtered_available, nil } -func criticalRequestPredicate(req *LLMRequest, _ backendmetrics.PodMetrics) bool { - return req.Critical +// podPredicate is a filter function to check whether a pod is desired. +type podPredicate func(req *types.LLMRequest, pod *types.PodMetrics) bool + +func queueThresholdPredicate(queueThreshold int) podPredicate { + return func(req *types.LLMRequest, pod *types.PodMetrics) bool { + return pod.WaitingQueueSize <= queueThreshold + } +} + +func kvCacheThresholdPredicate(kvCacheThreshold float64) podPredicate { + return func(req *types.LLMRequest, pod *types.PodMetrics) bool { + return pod.KVCacheUsagePercent <= kvCacheThreshold + } } -func noQueueAndLessThanKVCacheThresholdPredicate(queueThreshold int, kvCacheThreshold float64) podPredicate { - return func(req *LLMRequest, pod backendmetrics.PodMetrics) bool { - return pod.GetMetrics().WaitingQueueSize <= queueThreshold && pod.GetMetrics().KVCacheUsagePercent <= kvCacheThreshold +func (pp podPredicate) and(another podPredicate) podPredicate { + return func(req *types.LLMRequest, pod *types.PodMetrics) bool { + return pp(req, pod) && another(req, pod) } } diff --git a/pkg/epp/scheduling/filter_test.go b/pkg/epp/scheduling/filter_test.go index 127e6c21..543826d0 100644 --- a/pkg/epp/scheduling/filter_test.go +++ b/pkg/epp/scheduling/filter_test.go @@ -17,217 +17,48 @@ limitations under the License. package scheduling import ( + "context" "errors" "testing" - "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" - "k8s.io/apimachinery/pkg/types" + k8stypes "k8s.io/apimachinery/pkg/types" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) func TestFilter(t *testing.T) { - logger := logutil.NewTestLogger() - tests := []struct { name string - req *LLMRequest - input []*backendmetrics.FakePodMetrics - output []*backendmetrics.FakePodMetrics + req *types.LLMRequest + input []*types.PodMetrics + output []*types.PodMetrics err bool - filter *filter + filter *decisionTreeFilter }{ { name: "simple filter without successor, failure", - filter: &filter{filter: func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { - return nil, errors.New("filter error") - }}, - err: true, - }, - { - name: "default filter, critical request", - filter: defaultFilter, - req: &LLMRequest{ - Model: "critical", - ResolvedTargetModel: "critical", - Critical: true, - }, - // pod2 will be picked because it has relatively low queue size, with the requested - // model being active, and has low KV cache. - input: []*backendmetrics.FakePodMetrics{ - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - }, - }, - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.1, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, - }, - }, - }, - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - }, - }, - }, - }, - output: []*backendmetrics.FakePodMetrics{ - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.1, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, - }, - }, - }, - }, - }, - { - name: "default filter, sheddable request, accepted", - filter: defaultFilter, - req: &LLMRequest{ - Model: "sheddable", - ResolvedTargetModel: "sheddable", - Critical: false, - }, - // pod1 will be picked because it has capacity for the sheddable request. - input: []*backendmetrics.FakePodMetrics{ - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - }, - }, - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.1, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, - }, - }, - }, - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - }, - }, - }, - }, - output: []*backendmetrics.FakePodMetrics{ - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, + filter: &decisionTreeFilter{ + current: &basicFilter{ + name: "error", + filter: func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { + return nil, errors.New("filter error") }, }, }, - }, - { - name: "default filter, sheddable request, dropped", - filter: defaultFilter, - req: &LLMRequest{ - Model: "sheddable", - ResolvedTargetModel: "sheddable", - Critical: false, - }, - // All pods have higher KV cache thant the threshold, so the sheddable request will be - // dropped. - input: []*backendmetrics.FakePodMetrics{ - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.9, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - }, - }, - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.85, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, - }, - }, - }, - { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "pod3"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.85, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - }, - }, - }, - }, - output: []*backendmetrics.FakePodMetrics{}, - err: true, + err: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.filter.Filter(logger, test.req, toInterface(test.input)) + ctx := types.NewContext(context.Background(), test.req, test.input) + got, err := test.filter.Filter(ctx, test.input) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, toStruct(got)); diff != "" { + if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -235,26 +66,24 @@ func TestFilter(t *testing.T) { } func TestFilterFunc(t *testing.T) { - logger := logutil.NewTestLogger() - tests := []struct { name string f filterFunc - req *LLMRequest - input []*backendmetrics.FakePodMetrics - output []*backendmetrics.FakePodMetrics + req *types.LLMRequest + input []*types.PodMetrics + output []*types.PodMetrics err bool }{ { name: "least queuing empty input", f: leastQueuingFilterFunc, - input: []*backendmetrics.FakePodMetrics{}, - output: []*backendmetrics.FakePodMetrics{}, + input: []*types.PodMetrics{}, + output: []*types.PodMetrics{}, }, { name: "least queuing", f: leastQueuingFilterFunc, - input: []*backendmetrics.FakePodMetrics{ + input: []*types.PodMetrics{ { Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, @@ -271,7 +100,7 @@ func TestFilterFunc(t *testing.T) { }, }, }, - output: []*backendmetrics.FakePodMetrics{ + output: []*types.PodMetrics{ { Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, @@ -287,13 +116,13 @@ func TestFilterFunc(t *testing.T) { { name: "least kv cache empty input", f: leastKVCacheFilterFunc, - input: []*backendmetrics.FakePodMetrics{}, - output: []*backendmetrics.FakePodMetrics{}, + input: []*types.PodMetrics{}, + output: []*types.PodMetrics{}, }, { name: "least kv cache", f: leastKVCacheFilterFunc, - input: []*backendmetrics.FakePodMetrics{ + input: []*types.PodMetrics{ { Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0, @@ -310,7 +139,7 @@ func TestFilterFunc(t *testing.T) { }, }, }, - output: []*backendmetrics.FakePodMetrics{ + output: []*types.PodMetrics{ { Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0, @@ -324,9 +153,9 @@ func TestFilterFunc(t *testing.T) { }, }, { - name: "noQueueAndLessThanKVCacheThresholdPredicate", - f: toFilterFunc(noQueueAndLessThanKVCacheThresholdPredicate(0, 0.8)), - input: []*backendmetrics.FakePodMetrics{ + name: "lowQueueAndLessThanKVCacheThresholdPredicate", + f: toFilterFunc(queueThresholdPredicate(0).and(kvCacheThresholdPredicate(0.8))), + input: []*types.PodMetrics{ { // This pod should be returned. Metrics: &backendmetrics.Metrics{ @@ -349,7 +178,7 @@ func TestFilterFunc(t *testing.T) { }, }, }, - output: []*backendmetrics.FakePodMetrics{ + output: []*types.PodMetrics{ { Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, @@ -358,72 +187,17 @@ func TestFilterFunc(t *testing.T) { }, }, }, - { - name: "low LoRA cost", - f: toFilterFunc(lowLoRACostPredicate), - req: &LLMRequest{ - Model: "model", - ResolvedTargetModel: "model", - }, - input: []*backendmetrics.FakePodMetrics{ - // ActiveModels include input model, should be returned. - { - Metrics: &backendmetrics.Metrics{ - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "model": 1, - }, - }, - }, - // Input model is not active, however the server has room to load another adapter. - { - Metrics: &backendmetrics.Metrics{ - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "another-model": 1, - }, - }, - }, - // Input is not active, and the server has reached max active models. - { - Metrics: &backendmetrics.Metrics{ - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - }, - }, - }, - output: []*backendmetrics.FakePodMetrics{ - { - Metrics: &backendmetrics.Metrics{ - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "model": 1, - }, - }, - }, - { - Metrics: &backendmetrics.Metrics{ - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "another-model": 1, - }, - }, - }, - }, - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.f(logger, test.req, toInterface(test.input)) + ctx := types.NewContext(context.Background(), test.req, test.input) + got, err := test.f(ctx, test.input) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, toStruct(got)); diff != "" { + if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -433,8 +207,6 @@ func TestFilterFunc(t *testing.T) { // TestLoRASoftAffinityDistribution tests that the loRASoftAffinityFilter function // properly distributes requests according to the loraAffinityThreshold func TestLoRASoftAffinityDistribution(t *testing.T) { - logger := logutil.NewTestLogger() - const ( testModelName = "test-model" testAffinityModel = "test-affinity-model" @@ -455,15 +227,15 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }() // Create a test request and pods - req := &LLMRequest{ + req := &types.LLMRequest{ Model: testAffinityModel, ResolvedTargetModel: testAffinityModel, } // Test setup: One affinity pod and one available pod - pods := []*backendmetrics.FakePodMetrics{ + pods := []*types.PodMetrics{ { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "affinity-pod"}}, + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "affinity-pod"}}, Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ @@ -472,13 +244,14 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: types.NamespacedName{Name: "available-pod"}}, + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "available-pod"}}, Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{}, }, }, } + ctx := types.NewContext(context.Background(), req, pods) // Run the filter function multiple times and count the results affinityCount := 0 @@ -489,7 +262,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { expectedAvailabilityPercent := 100 - expectedAffinityPercent for i := 0; i < numIterations; i++ { - result, err := loRASoftAffinityFilter(logger, req, toInterface(pods)) + result, err := loRASoftAffinityFilterFunc(ctx, pods) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -533,22 +306,3 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { actualAvailablePercent, availableLowerBound, availableUpperBound) } } - -func toInterface(input []*backendmetrics.FakePodMetrics) []backendmetrics.PodMetrics { - output := []backendmetrics.PodMetrics{} - for _, i := range input { - output = append(output, i) - } - return output -} - -func toStruct(input []backendmetrics.PodMetrics) []*backendmetrics.FakePodMetrics { - if input == nil { - return nil - } - output := []*backendmetrics.FakePodMetrics{} - for _, i := range input { - output = append(output, i.(*backendmetrics.FakePodMetrics)) - } - return output -} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index e874724d..8679ffba 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -22,10 +22,9 @@ import ( "fmt" "math/rand" - "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -67,89 +66,91 @@ func LoadConfig() Config { var config = LoadConfig() var ( - defaultFilter = &filter{ - name: "critical request", - filter: toFilterFunc(criticalRequestPredicate), - nextOnSuccess: lowLatencyFilter, - nextOnFailure: sheddableRequestFilter, - } - - // queueLoRAAndKVCacheFilter applied least queue -> low cost lora -> least KV Cache filter - queueLoRAAndKVCacheFilter = &filter{ - name: "least queuing", - filter: leastQueuingFilterFunc, - nextOnSuccessOrFailure: &filter{ - name: "low cost LoRA", - filter: loRASoftAffinityFilter, - nextOnSuccessOrFailure: &filter{ - name: "least KV cache percent", - filter: leastKVCacheFilterFunc, + lowLatencyFilter = &decisionTreeFilter{ + current: lowQueueFilter, + nextOnSuccess: &decisionTreeFilter{ + current: loRAAffinityFilter, + nextOnSuccessOrFailure: &decisionTreeFilter{ + current: leastQueueFilter, + nextOnSuccessOrFailure: &decisionTreeFilter{ + current: leastKVCacheFilter, + }, }, }, - } - - // queueAndKVCacheFilter applies least queue followed by least KV Cache filter - queueAndKVCacheFilter = &filter{ - name: "least queuing", - filter: leastQueuingFilterFunc, - nextOnSuccessOrFailure: &filter{ - name: "least KV cache percent", - filter: leastKVCacheFilterFunc, - }, - } - - lowLatencyFilter = &filter{ - name: "low queueing filter", - filter: toFilterFunc((lowQueueingPodPredicate)), - nextOnSuccess: &filter{ - name: "affinity LoRA", - filter: loRASoftAffinityFilter, - nextOnSuccessOrFailure: queueAndKVCacheFilter, + nextOnFailure: &decisionTreeFilter{ + current: leastQueueFilter, + nextOnSuccessOrFailure: &decisionTreeFilter{ + current: loRAAffinityFilter, + nextOnSuccessOrFailure: &decisionTreeFilter{ + current: leastKVCacheFilter, + }, + }, }, - nextOnFailure: queueLoRAAndKVCacheFilter, } - sheddableRequestFilter = &filter{ + sheddableRequestFilter = &decisionTreeFilter{ // When there is at least one model server that's not queuing requests, and still has KV // cache below a certain threshold, we consider this model server has capacity to handle // a sheddable request without impacting critical requests. - name: "has capacity for sheddable requests", - filter: toFilterFunc(noQueueAndLessThanKVCacheThresholdPredicate(config.QueueThresholdCritical, config.KVCacheThreshold)), - nextOnSuccess: queueLoRAAndKVCacheFilter, + current: hasCapacityFilter, + nextOnSuccess: lowLatencyFilter, // If all pods are queuing or running above the KVCache threshold, we drop the sheddable // request to make room for critical requests. - nextOnFailure: &filter{ - name: "drop request", - filter: func(logger logr.Logger, req *LLMRequest, pods []backendmetrics.PodMetrics) ([]backendmetrics.PodMetrics, error) { - logger.V(logutil.DEFAULT).Info("Request dropped", "request", req) - return []backendmetrics.PodMetrics{}, errutil.Error{ - Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", - } - }, + nextOnFailure: dropRequestFilter, + } + + hasCapacityFilter = &basicFilter{ + name: "has capacity for sheddable requests", + filter: toFilterFunc(queueThresholdPredicate(config.QueueThresholdCritical).and(kvCacheThresholdPredicate(config.KVCacheThreshold))), + } + + dropRequestFilter = &basicFilter{ + name: "drop request", + filter: func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { + ctx.Logger.V(logutil.DEFAULT).Info("Request dropped", "request", ctx.Req) + return []*types.PodMetrics{}, errutil.Error{ + Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", + } }, } ) -func NewScheduler(datastore datastore.Datastore) *Scheduler { +func NewScheduler(datastore Datastore) *Scheduler { return &Scheduler{ - datastore: datastore, - filter: defaultFilter, + datastore: datastore, + criticalRequestFilter: lowLatencyFilter, + sheddableRequestFilter: sheddableRequestFilter, } } type Scheduler struct { - datastore datastore.Datastore - filter Filter + datastore Datastore + criticalRequestFilter Filter + sheddableRequestFilter Filter +} + +type Datastore interface { + PodGetAll() []backendmetrics.PodMetrics } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backendmetrics.PodMetrics, err error) { +func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (targetPod types.Pod, err error) { logger := log.FromContext(ctx).WithValues("request", req) - podMetrics := s.datastore.PodGetAll() - logger.V(logutil.DEBUG).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", podMetrics)) + // Snapshot pod metrics from the datastore to: + // 1. Reduce concurrent access to the datastore. + // 2. Ensure consistent data during the scheduling operation of a request. + sCtx := types.NewContext(ctx, req, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) + logger.V(logutil.DEBUG).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", sCtx.PodsSnapshot)) + + var filter Filter + if req.Critical { + filter = s.criticalRequestFilter + } else { + filter = s.sheddableRequestFilter + } - pods, err := s.filter.Filter(logger, req, podMetrics) + pods, err := filter.Filter(sCtx, sCtx.PodsSnapshot) if err != nil || len(pods) == 0 { return nil, fmt.Errorf("failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go new file mode 100644 index 00000000..3fd3fb24 --- /dev/null +++ b/pkg/epp/scheduling/scheduler_test.go @@ -0,0 +1,232 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + k8stypes "k8s.io/apimachinery/pkg/types" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +func TestSchedule(t *testing.T) { + tests := []struct { + name string + req *types.LLMRequest + input []*backendmetrics.FakePodMetrics + output types.Pod + err bool + }{ + { + name: "critical request", + req: &types.LLMRequest{ + Model: "critical", + ResolvedTargetModel: "critical", + Critical: true, + }, + // pod2 will be picked because it has relatively low queue size, with the requested + // model being active, and has low KV cache. + input: []*backendmetrics.FakePodMetrics{ + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + }, + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.1, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + }, + }, + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + }, + }, + }, + }, + output: &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.1, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + WaitingModels: map[string]int{}, + }, + }, + }, + { + name: "sheddable request, accepted", + req: &types.LLMRequest{ + Model: "sheddable", + ResolvedTargetModel: "sheddable", + Critical: false, + }, + // pod1 will be picked because it has capacity for the sheddable request. + input: []*backendmetrics.FakePodMetrics{ + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + }, + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.1, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + }, + }, + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + }, + }, + }, + }, + output: &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + WaitingModels: map[string]int{}, + }, + }, + }, + { + name: "sheddable request, dropped", + req: &types.LLMRequest{ + Model: "sheddable", + ResolvedTargetModel: "sheddable", + Critical: false, + }, + // All pods have higher KV cache thant the threshold, so the sheddable request will be + // dropped. + input: []*backendmetrics.FakePodMetrics{ + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + }, + }, + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.85, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + }, + }, + { + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.85, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + }, + }, + }, + }, + output: nil, + err: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheduler := NewScheduler(&fakeDataStore{pods: test.input}) + got, err := scheduler.Schedule(context.Background(), test.req) + if test.err != (err != nil) { + t.Errorf("Unexpected error, got %v, want %v", err, test.err) + } + + if diff := cmp.Diff(test.output, got); diff != "" { + t.Errorf("Unexpected output (-want +got): %v", diff) + } + }) + } +} + +type fakeDataStore struct { + pods []*backendmetrics.FakePodMetrics +} + +func (fds *fakeDataStore) PodGetAll() []backendmetrics.PodMetrics { + pm := make([]backendmetrics.PodMetrics, 0, len(fds.pods)) + for _, pod := range fds.pods { + pm = append(pm, pod) + } + return pm +} diff --git a/pkg/epp/scheduling/types.go b/pkg/epp/scheduling/types.go deleted file mode 100644 index 29e6648d..00000000 --- a/pkg/epp/scheduling/types.go +++ /dev/null @@ -1,27 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 scheduling - -// LLMRequest is a structured representation of the fields we parse out of the LLMRequest body. -type LLMRequest struct { - Model string - // Target models is a map of target model name to weight. - TargetModels map[string]int - // Resolved target model is the final target model after traffic split. - ResolvedTargetModel string - Critical bool -} diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go new file mode 100644 index 00000000..9450652e --- /dev/null +++ b/pkg/epp/scheduling/types/types.go @@ -0,0 +1,88 @@ +/* +Copyright 2025 The Kubernetes 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 types + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/log" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" +) + +// LLMRequest is a structured representation of the fields we parse out of the LLMRequest body. +type LLMRequest struct { + Model string + // Target models is a map of target model name to weight. + TargetModels map[string]int + // Resolved target model is the final target model after traffic split. + ResolvedTargetModel string + Critical bool +} + +// Context holds contextual information during a scheduling operation. +type Context struct { + context.Context + Logger logr.Logger + Req *LLMRequest + PodsSnapshot []*PodMetrics +} + +type Pod interface { + GetPod() *backendmetrics.Pod + GetMetrics() *backendmetrics.Metrics + String() string +} + +func (pm *PodMetrics) String() string { + if pm == nil { + return "" + } + return fmt.Sprintf("%+v", *pm) +} + +func (pm *PodMetrics) GetPod() *backendmetrics.Pod { + return pm.Pod +} + +func (pm *PodMetrics) GetMetrics() *backendmetrics.Metrics { + return pm.Metrics +} + +type PodMetrics struct { + *backendmetrics.Pod + *backendmetrics.Metrics +} + +func NewContext(ctx context.Context, req *LLMRequest, pods []*PodMetrics) *Context { + logger := log.FromContext(ctx).WithValues("request", req) + return &Context{ + Context: ctx, + Logger: logger, + Req: req, + PodsSnapshot: pods, + } +} + +func ToSchedulerPodMetrics(pods []backendmetrics.PodMetrics) []*PodMetrics { + pm := make([]*PodMetrics, 0, len(pods)) + for _, pod := range pods { + pm = append(pm, &PodMetrics{pod.GetPod().Clone(), pod.GetMetrics().Clone()}) + } + return pm +} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 1c5eca18..93432637 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -73,7 +73,7 @@ import ( const ( port = runserver.DefaultGrpcPort - metricsPort = 8888 + metricsPort = 8889 ) var ( @@ -157,6 +157,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -165,6 +166,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg2": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -173,6 +175,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, }, }, wantHeaders: []*configPb.HeaderValueOption{ @@ -212,6 +215,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 200, @@ -220,6 +224,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg2": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 6, @@ -227,6 +232,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { ActiveModels: map[string]int{ "foo": 1, }, + WaitingModels: map[string]int{}, }, }, wantHeaders: []*configPb.HeaderValueOption{ @@ -266,6 +272,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -274,6 +281,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -282,6 +290,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantHeaders: []*configPb.HeaderValueOption{}, @@ -308,6 +317,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -316,6 +326,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -324,6 +335,7 @@ func TestKubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantHeaders: []*configPb.HeaderValueOption{ @@ -496,6 +508,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -504,6 +517,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg2": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -512,6 +526,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` @@ -578,6 +593,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "bar": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 200, @@ -586,6 +602,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg2": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 6, @@ -593,6 +610,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { ActiveModels: map[string]int{ "foo": 1, }, + WaitingModels: map[string]int{}, }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` @@ -659,6 +677,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -667,6 +686,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -675,6 +695,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantErr: false, @@ -704,6 +725,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -712,6 +734,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -720,6 +743,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` @@ -812,6 +836,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -820,6 +845,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -828,6 +854,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` @@ -920,6 +947,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -928,6 +956,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -936,6 +965,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantMetrics: map[string]string{`inference_model_request_total`: ` @@ -1029,6 +1059,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -1037,6 +1068,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -1045,6 +1077,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantErr: false, @@ -1125,6 +1158,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(1): { WaitingQueueSize: 0, @@ -1133,6 +1167,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, fakePod(2): { WaitingQueueSize: 10, @@ -1141,6 +1176,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "foo": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantErr: false, @@ -1470,6 +1506,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { "bar": 1, "sql-lora-1fdg3": 1, }, + WaitingModels: map[string]int{}, }, }, wantMetrics: map[string]string{`inference_pool_ready_pods`: ` From 66808d484bbe97ec717f3bf07111d698cf2235f6 Mon Sep 17 00:00:00 2001 From: Sachin Varghese Date: Mon, 7 Apr 2025 11:20:40 -0400 Subject: [PATCH 201/260] Getting started docs version bump (#654) --- site-src/guides/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 367ca902..0f1fe036 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -58,7 +58,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv === "Latest Release" ```bash - VERSION=v0.2.0 + VERSION=v0.3.0 kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/releases/download/$VERSION/manifests.yaml ``` From 6058b09f38bc3f88fc92c3839f04ccde781a4dff Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Mon, 7 Apr 2025 13:02:39 -0700 Subject: [PATCH 202/260] expose "Normalized Time Per Output Token" (NTPOT) metric (#643) * add tpot to inference gateway exposed metrics * add tpot to inference gateway exposed metrics * update logging and add ntpot logging to server.go * update logging and add ntpot logging to server.go * fix lint error * change metric name from ntpot to normalized time per output token * update metrics.md --- pkg/epp/handlers/server.go | 1 + pkg/epp/handlers/streamingserver.go | 2 + pkg/epp/metrics/metrics.go | 37 ++++++ pkg/epp/metrics/metrics_test.go | 122 ++++++++++++++++-- ...lized_time_per_output_token_seconds_metric | 50 +++++++ site-src/guides/metrics.md | 1 + 6 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 pkg/epp/metrics/testdata/normalized_time_per_output_token_seconds_metric diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index f6f375dd..862a73b4 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -129,6 +129,7 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) + metrics.RecordNormalizedTimePerOutputToken(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp, reqCtx.Usage.CompletionTokens) } if reqCtx.modelServerStreaming { logger.V(logutil.DEBUG).Info("Request context after HandleResponseBody", "context", reqCtx) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 0e9020d8..88963f47 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -184,6 +184,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) reqCtx.ResponseCompleteTimestamp = time.Now() metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) + metrics.RecordNormalizedTimePerOutputToken(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp, reqCtx.Usage.CompletionTokens) } reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ @@ -226,6 +227,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) + metrics.RecordNormalizedTimePerOutputToken(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp, reqCtx.Usage.CompletionTokens) } } } diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 434b8381..b474df36 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -131,6 +131,21 @@ var ( []string{"model_name"}, ) + // NTPOT - Normalized Time Per Output Token + NormalizedTimePerOutputToken = compbasemetrics.NewHistogramVec( + &compbasemetrics.HistogramOpts{ + Subsystem: InferenceModelComponent, + Name: "normalized_time_per_output_token_seconds", + Help: "Inference model latency divided by number of output tokens in seconds for each model and target model.", + // From few milliseconds per token to multiple seconds per token + Buckets: []float64{ + 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, + }, + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"model_name", "target_model_name"}, + ) + // Inference Pool Metrics inferencePoolAvgKVCache = compbasemetrics.NewGaugeVec( &compbasemetrics.GaugeOpts{ @@ -176,6 +191,7 @@ func Register() { legacyregistry.MustRegister(inputTokens) legacyregistry.MustRegister(outputTokens) legacyregistry.MustRegister(runningRequests) + legacyregistry.MustRegister(NormalizedTimePerOutputToken) legacyregistry.MustRegister(inferencePoolAvgKVCache) legacyregistry.MustRegister(inferencePoolAvgQueueSize) @@ -231,6 +247,27 @@ func RecordOutputTokens(modelName, targetModelName string, size int) { } } +// RecordNormalizedTimePerOutputToken (NTPOT) records the normalized time per output token. +func RecordNormalizedTimePerOutputToken(ctx context.Context, modelName, targetModelName string, received time.Time, complete time.Time, outputTokenCount int) bool { + if !complete.After(received) { + log.FromContext(ctx).Error(nil, "Request latency values are invalid for NTPOT calculation", + "modelName", modelName, "targetModelName", targetModelName, "completeTime", complete, "receivedTime", received) + return false + } + + if outputTokenCount <= 0 { + log.FromContext(ctx).Error(nil, "Output token count must be positive for NTPOT calculation", + "modelName", modelName, "targetModelName", targetModelName, "outputTokenCount", outputTokenCount) + return false + } + + elapsedSeconds := complete.Sub(received).Seconds() + secondsPerToken := elapsedSeconds / float64(outputTokenCount) + + NormalizedTimePerOutputToken.WithLabelValues(modelName, targetModelName).Observe(secondsPerToken) + return true +} + // IncRunningRequests increases the current running requests. func IncRunningRequests(modelName string) { if modelName != "" { diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index dc4c7044..b5f19e6d 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -29,16 +29,17 @@ import ( ) const ( - RequestTotalMetric = InferenceModelComponent + "_request_total" - RequestErrorTotalMetric = InferenceModelComponent + "_request_error_total" - RequestLatenciesMetric = InferenceModelComponent + "_request_duration_seconds" - RequestSizesMetric = InferenceModelComponent + "_request_sizes" - ResponseSizesMetric = InferenceModelComponent + "_response_sizes" - InputTokensMetric = InferenceModelComponent + "_input_tokens" - OutputTokensMetric = InferenceModelComponent + "_output_tokens" - RunningRequestsMetric = InferenceModelComponent + "_running_requests" - KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" - QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" + RequestTotalMetric = InferenceModelComponent + "_request_total" + RequestErrorTotalMetric = InferenceModelComponent + "_request_error_total" + RequestLatenciesMetric = InferenceModelComponent + "_request_duration_seconds" + RequestSizesMetric = InferenceModelComponent + "_request_sizes" + ResponseSizesMetric = InferenceModelComponent + "_response_sizes" + InputTokensMetric = InferenceModelComponent + "_input_tokens" + OutputTokensMetric = InferenceModelComponent + "_output_tokens" + NormalizedTimePerOutputTokenMetric = InferenceModelComponent + "_normalized_time_per_output_token_seconds" + RunningRequestsMetric = InferenceModelComponent + "_running_requests" + KVCacheAvgUsageMetric = InferencePoolComponent + "_average_kv_cache_utilization" + QueueAvgSizeMetric = InferencePoolComponent + "_average_queue_size" ) func TestRecordRequestCounterandSizes(t *testing.T) { @@ -252,6 +253,107 @@ func TestRecordRequestLatencies(t *testing.T) { } } +func TestRecordNormalizedTimePerOutputToken(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + timeBaseline := time.Now() + type tokenRequests struct { + modelName string + targetModelName string + receivedTime time.Time + completeTime time.Time + outputTokens int + } + scenarios := []struct { + name string + reqs []tokenRequests + invalid bool + }{ + { + name: "multiple requests", + reqs: []tokenRequests{ + { + modelName: "m10", + targetModelName: "t10", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 1000), + outputTokens: 100, // 10ms per token + }, + { + modelName: "m10", + targetModelName: "t10", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 1600), + outputTokens: 80, // 20ms per token + }, + { + modelName: "m10", + targetModelName: "t11", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 6000), + outputTokens: 300, // 20ms per token + }, + { + modelName: "m20", + targetModelName: "t20", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 2400), + outputTokens: 400, // 6ms per token + }, + }, + }, + { + name: "invalid elapsed time", + reqs: []tokenRequests{ + { + modelName: "m10", + targetModelName: "t10", + receivedTime: timeBaseline.Add(time.Millisecond * 10), + completeTime: timeBaseline, + outputTokens: 100, + }, + }, + invalid: true, + }, + { + name: "invalid token count", + reqs: []tokenRequests{ + { + modelName: "m10", + targetModelName: "t10", + receivedTime: timeBaseline, + completeTime: timeBaseline.Add(time.Millisecond * 1000), + outputTokens: 0, // Invalid: zero tokens + }, + }, + invalid: true, + }, + } + Register() + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + for _, req := range scenario.reqs { + success := RecordNormalizedTimePerOutputToken(ctx, req.modelName, req.targetModelName, req.receivedTime, req.completeTime, req.outputTokens) + if success == scenario.invalid { + t.Errorf("got record success(%v), but the request expects invalid(%v)", success, scenario.invalid) + } + } + + wantLatencyPerToken, err := os.Open("testdata/normalized_time_per_output_token_seconds_metric") + defer func() { + if err := wantLatencyPerToken.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantLatencyPerToken, NormalizedTimePerOutputTokenMetric); err != nil { + t.Error(err) + } + }) + } +} + func TestRecordResponseMetrics(t *testing.T) { type responses struct { modelName string diff --git a/pkg/epp/metrics/testdata/normalized_time_per_output_token_seconds_metric b/pkg/epp/metrics/testdata/normalized_time_per_output_token_seconds_metric new file mode 100644 index 00000000..bb6e9373 --- /dev/null +++ b/pkg/epp/metrics/testdata/normalized_time_per_output_token_seconds_metric @@ -0,0 +1,50 @@ +# HELP inference_model_normalized_time_per_output_token_seconds [ALPHA] Inference model latency divided by number of output tokens in seconds for each model and target model. +# TYPE inference_model_normalized_time_per_output_token_seconds histogram +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.001"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.002"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.005"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.01"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.02"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.05"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.1"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.2"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="0.5"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="1.0"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="2.0"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="5.0"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="10.0"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t10", le="+Inf"} 2 +inference_model_normalized_time_per_output_token_seconds_sum{model_name="m10", target_model_name="t10"} 0.03 +inference_model_normalized_time_per_output_token_seconds_count{model_name="m10", target_model_name="t10"} 2 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.001"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.002"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.005"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.01"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.02"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.05"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.1"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.2"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="0.5"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="1.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="2.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="5.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="10.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m10", target_model_name="t11", le="+Inf"} 1 +inference_model_normalized_time_per_output_token_seconds_sum{model_name="m10", target_model_name="t11"} 0.02 +inference_model_normalized_time_per_output_token_seconds_count{model_name="m10", target_model_name="t11"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.001"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.002"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.005"} 0 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.01"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.02"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.05"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.1"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.2"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="0.5"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="1.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="2.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="5.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="10.0"} 1 +inference_model_normalized_time_per_output_token_seconds_bucket{model_name="m20", target_model_name="t20", le="+Inf"} 1 +inference_model_normalized_time_per_output_token_seconds_sum{model_name="m20", target_model_name="t20"} 0.006 +inference_model_normalized_time_per_output_token_seconds_count{model_name="m20", target_model_name="t20"} 1 diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index a781f721..d16c7d47 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -26,6 +26,7 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ | inference_model_request_total | Counter | The counter of requests broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_request_error_total | Counter | The counter of requests errors broken out for each model. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_request_duration_seconds | Distribution | Distribution of response latency. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | +| normalized_time_per_output_token_seconds | Distribution | Distribution of ntpot (response latency per output token) | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_request_sizes | Distribution | Distribution of request size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_response_sizes | Distribution | Distribution of response size in bytes. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | | inference_model_input_tokens | Distribution | Distribution of input token count. | `model_name`=<model-name>
`target_model_name`=<target-model-name> | ALPHA | From 9181b471190a471cb7a7a913a7db252a93f0b67e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 19:20:39 -0700 Subject: [PATCH 203/260] Bump github.com/onsi/ginkgo/v2 from 2.23.3 to 2.23.4 (#657) Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.23.3 to 2.23.4. - [Release notes](https://github.com/onsi/ginkgo/releases) - [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md) - [Commits](https://github.com/onsi/ginkgo/compare/v2.23.3...v2.23.4) --- updated-dependencies: - dependency-name: github.com/onsi/ginkgo/v2 dependency-version: 2.23.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 11 ++++++----- go.sum | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index fba85f91..e3239967 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.7.0 - github.com/onsi/ginkgo/v2 v2.23.3 + github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.36.3 github.com/prometheus/client_golang v1.21.1 github.com/prometheus/client_model v0.6.1 @@ -65,7 +65,7 @@ require ( github.com/google/cel-go v0.22.0 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect @@ -104,17 +104,18 @@ require ( go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.23.0 // indirect + golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.37.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.30.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.30.0 // indirect + golang.org/x/tools v0.31.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect diff --git a/go.sum b/go.sum index 2bcff108..6ea76a79 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -151,8 +151,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= -github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -162,6 +162,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= @@ -213,6 +215,8 @@ go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -228,8 +232,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -248,8 +252,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -263,8 +267,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= -golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 207f00def1b721d007310b7f5d7c9ae89aa31031 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 19:36:39 -0700 Subject: [PATCH 204/260] Bump google.golang.org/grpc from 1.71.0 to 1.71.1 (#658) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.71.0 to 1.71.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.71.0...v1.71.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.71.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e3239967..12d65014 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 - google.golang.org/grpc v1.71.0 + google.golang.org/grpc v1.71.1 google.golang.org/protobuf v1.36.6 k8s.io/api v0.32.3 k8s.io/apiextensions-apiserver v0.32.3 diff --git a/go.sum b/go.sum index 6ea76a79..ece2d3c3 100644 --- a/go.sum +++ b/go.sum @@ -281,8 +281,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1: google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= +google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 5c908e3fafc0cf754e5d7679e51b7b8f53986a49 Mon Sep 17 00:00:00 2001 From: Xiaolin Lin Date: Tue, 8 Apr 2025 11:10:40 -0400 Subject: [PATCH 205/260] Fix links and description in implementations.md (#650) * Correct Envoy AI Gateway links Signed-off-by: Xiaolin Lin * fixes Signed-off-by: Xiaolin Lin * more fix Signed-off-by: Xiaolin Lin --------- Signed-off-by: Xiaolin Lin --- site-src/implementations.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/site-src/implementations.md b/site-src/implementations.md index 8a95119d..dc15b297 100644 --- a/site-src/implementations.md +++ b/site-src/implementations.md @@ -2,7 +2,7 @@ This project has several implementations that are planned or in progress: -* [Envoy Gateway][1] +* [Envoy AI Gateway][1] * [Kgateway][2] * [Google Kubernetes Engine][3] @@ -10,20 +10,20 @@ This project has several implementations that are planned or in progress: [2]:#kgateway [3]:#google-kubernetes-engine -## Envoy Gateway +## Envoy AI Gateway -[Envoy Gateway][eg-home] is an [Envoy][envoy-org] subproject for managing -Envoy-based application gateways. The supported APIs and fields of the Gateway -API are outlined [here][eg-supported]. Use the [quickstart][eg-quickstart] to -get Envoy Gateway running with Gateway API in a few simple steps. +[Envoy AI Gateway][aigw-home] is an open source project built on top of +[Envoy][envoy-org] and [Envoy Gateway][aigw-gateway] to handle request traffic +from application clients to GenAI services. The features and capabilities are outlined [here][aigw-capabilities]. Use the [quickstart][aigw-quickstart] to get Envoy AI Gateway running with Gateway API in a few simple steps. Progress towards supporting this project is tracked with a [GitHub -Issue](https://github.com/envoyproxy/gateway/issues/4423). +Issue](https://github.com/envoyproxy/ai-gateway/issues/423). -[eg-home]:https://gateway.envoyproxy.io/ +[aigw-home]:https://gateway.envoyproxy.io/ [envoy-org]:https://github.com/envoyproxy -[eg-supported]:https://gateway.envoyproxy.io/docs/tasks/quickstart/ -[eg-quickstart]:https://gateway.envoyproxy.io/docs/tasks/quickstart +[aigw-gateway]: https://gateway.envoyproxy.io/ +[aigw-capabilities]:https://aigateway.envoyproxy.io/docs/capabilities/ +[aigw-quickstart]:https://aigateway.envoyproxy.io/docs/capabilities/gateway-api-inference-extension ## Kgateway From f346ffb1bbb4bbf3d5a4ff2fc31a1f8954065b1a Mon Sep 17 00:00:00 2001 From: Se7en Date: Tue, 8 Apr 2025 23:10:47 +0800 Subject: [PATCH 206/260] fix manifests and description in the user guides (#652) * fix manifests and description in the user guides * add base model back --- config/manifests/inferencemodel.yaml | 4 +- config/manifests/vllm/gpu-deployment.yaml | 6 +- pkg/epp/README.md | 2 +- site-src/guides/adapter-rollout.md | 100 ++++++++++++---------- site-src/guides/index.md | 3 +- 5 files changed, 58 insertions(+), 57 deletions(-) diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 75c9bb17..67c91d0e 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -8,9 +8,8 @@ spec: poolRef: name: vllm-llama3-8b-instruct targetModels: - - name: food-review + - name: food-review-1 weight: 100 - --- apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel @@ -21,7 +20,6 @@ spec: criticality: Critical poolRef: name: vllm-llama3-8b-instruct - --- apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index e7cb193e..d62d4b02 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -243,12 +243,10 @@ metadata: data: configmap.yaml: | vLLMLoRAConfig: - name: vllm-llama3.1-8b-instruct + name: vllm-llama3-8b-instruct-adapters port: 8000 defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct ensureExist: models: - - id: food-review + - id: food-review-1 source: Kawon/llama3.1-food-finetune_v14_r8 - - id: cad-fabricator - source: redcathode/fabricator diff --git a/pkg/epp/README.md b/pkg/epp/README.md index 1bf47993..99d1bf06 100644 --- a/pkg/epp/README.md +++ b/pkg/epp/README.md @@ -1,5 +1,5 @@ # The EndPoint Picker (EPP) -This package provides the reference implementation for the Endpoint Picker (EPP). As demonistrated in the diagram below, it implements the [extension protocol](../../docs/proposals/004-endpoint-picker-protocol), enabling a proxy or gateway to request endpoint hints from an extension, and interacts with the model servers through the defined [model server protocol](../..//docs/proposals/003-model-server-protocol). +This package provides the reference implementation for the Endpoint Picker (EPP). As demonstrated in the diagram below, it implements the [extension protocol](../../docs/proposals/004-endpoint-picker-protocol), enabling a proxy or gateway to request endpoint hints from an extension, and interacts with the model servers through the defined [model server protocol](../..//docs/proposals/003-model-server-protocol). ![Architecture Diagram](../../docs/endpoint-picker.svg) diff --git a/site-src/guides/adapter-rollout.md b/site-src/guides/adapter-rollout.md index fdf62c3a..4e7a3667 100644 --- a/site-src/guides/adapter-rollout.md +++ b/site-src/guides/adapter-rollout.md @@ -18,28 +18,28 @@ Modify the LoRA syncer ConfigMap to initiate loading of the new adapter version. ```bash - kubectl edit configmap vllm-llama3-8b-instruct-adapters +kubectl edit configmap vllm-llama3-8b-instruct-adapters ``` Change the ConfigMap to match the following (note the new entry under models): ```yaml - apiVersion: v1 - kind: ConfigMap - metadata: - name: vllm-llama3-8b-instruct-adapters - data: - configmap.yaml: | - vLLMLoRAConfig: - name: vllm-llama3-8b-instruct-adapters - port: 8000 - defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct - ensureExist: - models: - - id: food-review-1 - source: Kawon/llama3.1-food-finetune_v14_r8 - - id: food-review-2 - source: Kawon/llama3.1-food-finetune_v14_r8 +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-llama3-8b-instruct-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama3-8b-instruct-adapters + port: 8000 + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct + ensureExist: + models: + - id: food-review-1 + source: Kawon/llama3.1-food-finetune_v14_r8 + - id: food-review-2 + source: Kawon/llama3.1-food-finetune_v14_r8 ``` The new adapter version is applied to the model servers live, without requiring a restart. @@ -51,35 +51,34 @@ Modify the InferenceModel to configure a canary rollout with traffic splitting. ```bash - kubectl edit inferencemodel food-review +kubectl edit inferencemodel food-review ``` Change the targetModels list in InferenceModel to match the following: ```yaml -apiVersion: inference.networking.x-k8s.io/v1alpha1 +apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-sample + name: food-review spec: modelName: food-review - criticality: Critical + criticality: Standard poolRef: - name: vllm-llama3-8b-instruct-pool + name: vllm-llama3-8b-instruct targetModels: - name: food-review-1 weight: 90 - name: food-review-2 weight: 10 - ``` The above configuration means one in every ten requests should be sent to the new version. Try it out: 1. Get the gateway IP: ```bash -IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}'); PORT=8081 +IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}'); PORT=80 ``` 2. Send a few requests as follows: @@ -98,34 +97,41 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ Modify the InferenceModel to direct 100% of the traffic to the latest version of the adapter. ```yaml -model: - name: food-review - targetModels: - targetModelName: food-review-2 - weight: 100 +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: food-review +spec: + modelName: food-review + criticality: Standard + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: food-review-2 + weight: 100 ``` Unload the older versions from the servers by updating the LoRA syncer ConfigMap to list the older version under the `ensureNotExist` list: ```yaml - apiVersion: v1 - kind: ConfigMap - metadata: - name: dynamic-lora-config - data: - configmap.yaml: | - vLLMLoRAConfig: - name: sql-loras-llama - port: 8000 - defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct - ensureExist: - models: - - id: food-review-2 - source: Kawon/llama3.1-food-finetune_v14_r8 - ensureNotExist: - models: - - id: food-review-1 - source: Kawon/llama3.1-food-finetune_v14_r8 +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-llama3-8b-instruct-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama3-8b-instruct-adapters + port: 8000 + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct + ensureExist: + models: + - id: food-review-2 + source: Kawon/llama3.1-food-finetune_v14_r8 + ensureNotExist: + models: + - id: food-review-1 + source: Kawon/llama3.1-food-finetune_v14_r8 ``` With this, all requests should be served by the new adapter version. diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 0f1fe036..df3d1760 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -70,8 +70,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Deploy InferenceModel - Deploy the sample InferenceModel which is configured to load balance traffic between the `food-review-0` and `food-review-1` - [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. + Deploy the sample InferenceModel which is configured to forward traffic to the `food-review-1` [LoRA adapter](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml From a107a291f0bd6fa02b42df8f74b3d7336742b3df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 08:10:55 -0700 Subject: [PATCH 207/260] Bump github.com/onsi/gomega from 1.36.3 to 1.37.0 (#659) Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.36.3 to 1.37.0. - [Release notes](https://github.com/onsi/gomega/releases) - [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md) - [Commits](https://github.com/onsi/gomega/compare/v1.36.3...v1.37.0) --- updated-dependencies: - dependency-name: github.com/onsi/gomega dependency-version: 1.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 12d65014..20cf017a 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.7.0 github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.36.3 + github.com/onsi/gomega v1.37.0 github.com/prometheus/client_golang v1.21.1 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.63.0 diff --git a/go.sum b/go.sum index ece2d3c3..cd6cd380 100644 --- a/go.sum +++ b/go.sum @@ -153,8 +153,8 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= -github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= From 27d3991f85c2a03e6f6012c838ad4312bcd684bc Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:30:40 +0000 Subject: [PATCH 208/260] adjust the gpu deployment to increase max batch size (#642) * adjust the gpu deployment to increase max batch size * Apply suggestions from code review --- config/manifests/vllm/gpu-deployment.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index d62d4b02..16f93882 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -24,9 +24,15 @@ spec: - "1" - "--port" - "8000" + - "--max-num-seq" + - "1024" + - "--compilation-config" + - "3" - "--enable-lora" - "--max-loras" - "2" + - "--max-lora-rank" + - "8" - "--max-cpu-loras" - "12" env: From 807d84bc2b826617c7a5ce9025f9a4958c5b5bee Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:50:40 +0000 Subject: [PATCH 209/260] Cleaning up config pkg (#663) --- config/default/kustomization.yaml | 151 ------------------ config/default/manager_metrics_patch.yaml | 4 - config/default/metrics_service.yaml | 17 -- .../network-policy/allow-metrics-traffic.yaml | 26 --- config/network-policy/kustomization.yaml | 2 - config/prometheus/kustomization.yaml | 2 - config/prometheus/monitor.yaml | 30 ---- config/rbac/inferencemodel_editor_role.yaml | 27 ---- config/rbac/inferencemodel_viewer_role.yaml | 23 --- config/rbac/inferencepool_editor_role.yaml | 27 ---- config/rbac/inferencepool_viewer_role.yaml | 23 --- config/rbac/kustomization.yaml | 29 ---- config/rbac/leader_election_role.yaml | 40 ----- config/rbac/leader_election_role_binding.yaml | 15 -- config/rbac/metrics_auth_role.yaml | 17 -- config/rbac/metrics_auth_role_binding.yaml | 12 -- config/rbac/metrics_reader_role.yaml | 9 -- config/rbac/role.yaml | 11 -- config/rbac/role_binding.yaml | 15 -- config/rbac/service_account.yaml | 8 - .../gateway_v1alpha1_inferencemodel.yaml | 17 -- .../gateway_v1alpha1_inferencepool.yaml | 11 -- config/samples/kustomization.yaml | 5 - 23 files changed, 521 deletions(-) delete mode 100644 config/default/kustomization.yaml delete mode 100644 config/default/manager_metrics_patch.yaml delete mode 100644 config/default/metrics_service.yaml delete mode 100644 config/network-policy/allow-metrics-traffic.yaml delete mode 100644 config/network-policy/kustomization.yaml delete mode 100644 config/prometheus/kustomization.yaml delete mode 100644 config/prometheus/monitor.yaml delete mode 100644 config/rbac/inferencemodel_editor_role.yaml delete mode 100644 config/rbac/inferencemodel_viewer_role.yaml delete mode 100644 config/rbac/inferencepool_editor_role.yaml delete mode 100644 config/rbac/inferencepool_viewer_role.yaml delete mode 100644 config/rbac/kustomization.yaml delete mode 100644 config/rbac/leader_election_role.yaml delete mode 100644 config/rbac/leader_election_role_binding.yaml delete mode 100644 config/rbac/metrics_auth_role.yaml delete mode 100644 config/rbac/metrics_auth_role_binding.yaml delete mode 100644 config/rbac/metrics_reader_role.yaml delete mode 100644 config/rbac/role.yaml delete mode 100644 config/rbac/role_binding.yaml delete mode 100644 config/rbac/service_account.yaml delete mode 100644 config/samples/gateway_v1alpha1_inferencemodel.yaml delete mode 100644 config/samples/gateway_v1alpha1_inferencepool.yaml delete mode 100644 config/samples/kustomization.yaml diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml deleted file mode 100644 index 1fd9939f..00000000 --- a/config/default/kustomization.yaml +++ /dev/null @@ -1,151 +0,0 @@ -# Adds namespace to all resources. -namespace: api-system - -# Value of this field is prepended to the -# names of all resources, e.g. a deployment named -# "wordpress" becomes "alices-wordpress". -# Note that it should also match with the prefix (text before '-') of the namespace -# field above. -namePrefix: api- - -# Labels to add to all resources and selectors. -#labels: -#- includeSelectors: true -# pairs: -# someName: someValue - -resources: -- ../crd -- ../rbac -- ../manager -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- ../webhook -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -#- ../certmanager -# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. -#- ../prometheus -# [METRICS] Expose the controller manager metrics service. -- metrics_service.yaml -# [NETWORK POLICY] Protect the /metrics endpoint and Webhook Server with NetworkPolicy. -# Only Pod(s) running a namespace labeled with 'metrics: enabled' will be able to gather the metrics. -# Only CR(s) which requires webhooks and are applied on namespaces labeled with 'webhooks: enabled' will -# be able to communicate with the Webhook Server. -#- ../network-policy - -# Uncomment the patches line if you enable Metrics, and/or are using webhooks and cert-manager -patches: -# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. -# More info: https://book.kubebuilder.io/reference/metrics -- path: manager_metrics_patch.yaml - target: - kind: Deployment - -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- path: manager_webhook_patch.yaml - -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. -# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection -#- path: webhookcainjection_patch.yaml - -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. -# Uncomment the following replacements to add the cert-manager CA injection annotations -#replacements: -# - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldPath: .metadata.namespace # namespace of the certificate CR -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - select: -# kind: CustomResourceDefinition -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 0 -# create: true -# - source: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldPath: .metadata.name -# targets: -# - select: -# kind: ValidatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - select: -# kind: MutatingWebhookConfiguration -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - select: -# kind: CustomResourceDefinition -# fieldPaths: -# - .metadata.annotations.[cert-manager.io/inject-ca-from] -# options: -# delimiter: '/' -# index: 1 -# create: true -# - source: # Add cert-manager annotation to the webhook Service -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.name # namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 0 -# create: true -# - source: -# kind: Service -# version: v1 -# name: webhook-service -# fieldPath: .metadata.namespace # namespace of the service -# targets: -# - select: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# fieldPaths: -# - .spec.dnsNames.0 -# - .spec.dnsNames.1 -# options: -# delimiter: '.' -# index: 1 -# create: true diff --git a/config/default/manager_metrics_patch.yaml b/config/default/manager_metrics_patch.yaml deleted file mode 100644 index 2aaef653..00000000 --- a/config/default/manager_metrics_patch.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# This patch adds the args to allow exposing the metrics endpoint using HTTPS -- op: add - path: /spec/template/spec/containers/0/args/0 - value: --metrics-bind-address=:8443 diff --git a/config/default/metrics_service.yaml b/config/default/metrics_service.yaml deleted file mode 100644 index 140d4943..00000000 --- a/config/default/metrics_service.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: controller-manager-metrics-service - namespace: system -spec: - ports: - - name: https - port: 8443 - protocol: TCP - targetPort: 8443 - selector: - control-plane: controller-manager diff --git a/config/network-policy/allow-metrics-traffic.yaml b/config/network-policy/allow-metrics-traffic.yaml deleted file mode 100644 index aae53668..00000000 --- a/config/network-policy/allow-metrics-traffic.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# This NetworkPolicy allows ingress traffic -# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those -# namespaces are able to gathering data from the metrics endpoint. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: allow-metrics-traffic - namespace: system -spec: - podSelector: - matchLabels: - control-plane: controller-manager - policyTypes: - - Ingress - ingress: - # This allows ingress traffic from any namespace with the label metrics: enabled - - from: - - namespaceSelector: - matchLabels: - metrics: enabled # Only from namespaces with this label - ports: - - port: 8443 - protocol: TCP diff --git a/config/network-policy/kustomization.yaml b/config/network-policy/kustomization.yaml deleted file mode 100644 index ec0fb5e5..00000000 --- a/config/network-policy/kustomization.yaml +++ /dev/null @@ -1,2 +0,0 @@ -resources: -- allow-metrics-traffic.yaml diff --git a/config/prometheus/kustomization.yaml b/config/prometheus/kustomization.yaml deleted file mode 100644 index ed137168..00000000 --- a/config/prometheus/kustomization.yaml +++ /dev/null @@ -1,2 +0,0 @@ -resources: -- monitor.yaml diff --git a/config/prometheus/monitor.yaml b/config/prometheus/monitor.yaml deleted file mode 100644 index aac24ef3..00000000 --- a/config/prometheus/monitor.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Prometheus Monitor Service (Metrics) -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: controller-manager-metrics-monitor - namespace: system -spec: - endpoints: - - path: /metrics - port: https # Ensure this is the name of the port that exposes HTTPS metrics - scheme: https - bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token - tlsConfig: - # TODO(user): The option insecureSkipVerify: true is not recommended for production since it disables - # certificate verification. This poses a significant security risk by making the system vulnerable to - # man-in-the-middle attacks, where an attacker could intercept and manipulate the communication between - # Prometheus and the monitored services. This could lead to unauthorized access to sensitive metrics data, - # compromising the integrity and confidentiality of the information. - # Please use the following options for secure configurations: - # caFile: /etc/metrics-certs/ca.crt - # certFile: /etc/metrics-certs/tls.crt - # keyFile: /etc/metrics-certs/tls.key - insecureSkipVerify: true - selector: - matchLabels: - control-plane: controller-manager diff --git a/config/rbac/inferencemodel_editor_role.yaml b/config/rbac/inferencemodel_editor_role.yaml deleted file mode 100644 index b175a9a3..00000000 --- a/config/rbac/inferencemodel_editor_role.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# permissions for end users to edit inferencemodels. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: inferencemodel-editor-role -rules: -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencemodels - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencemodels/status - verbs: - - get diff --git a/config/rbac/inferencemodel_viewer_role.yaml b/config/rbac/inferencemodel_viewer_role.yaml deleted file mode 100644 index 3b3e67f6..00000000 --- a/config/rbac/inferencemodel_viewer_role.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# permissions for end users to view inferencemodels. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: inferencemodel-viewer-role -rules: -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencemodels - verbs: - - get - - list - - watch -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencemodels/status - verbs: - - get diff --git a/config/rbac/inferencepool_editor_role.yaml b/config/rbac/inferencepool_editor_role.yaml deleted file mode 100644 index cc1f7c35..00000000 --- a/config/rbac/inferencepool_editor_role.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# permissions for end users to edit inferencepools. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: inferencepool-editor-role -rules: -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencepools - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencepools/status - verbs: - - get diff --git a/config/rbac/inferencepool_viewer_role.yaml b/config/rbac/inferencepool_viewer_role.yaml deleted file mode 100644 index 828e0022..00000000 --- a/config/rbac/inferencepool_viewer_role.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# permissions for end users to view inferencepools. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: inferencepool-viewer-role -rules: -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencepools - verbs: - - get - - list - - watch -- apiGroups: - - inference.networking.x-k8s.io - resources: - - inferencepools/status - verbs: - - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml deleted file mode 100644 index c3a52137..00000000 --- a/config/rbac/kustomization.yaml +++ /dev/null @@ -1,29 +0,0 @@ -resources: -# All RBAC will be applied under this service account in -# the deployment namespace. You may comment out this resource -# if your manager will use a service account that exists at -# runtime. Be sure to update RoleBinding and ClusterRoleBinding -# subjects if changing service account names. -- service_account.yaml -- role.yaml -- role_binding.yaml -- leader_election_role.yaml -- leader_election_role_binding.yaml -# The following RBAC configurations are used to protect -# the metrics endpoint with authn/authz. These configurations -# ensure that only authorized users and service accounts -# can access the metrics endpoint. Comment the following -# permissions if you want to disable this protection. -# More info: https://book.kubebuilder.io/reference/metrics.html -- metrics_auth_role.yaml -- metrics_auth_role_binding.yaml -- metrics_reader_role.yaml -# For each CRD, "Editor" and "Viewer" roles are scaffolded by -# default, aiding admins in cluster management. Those roles are -# not used by the Project itself. You can comment the following lines -# if you do not want those helpers be installed with your Project. -- inferencemodel_editor_role.yaml -- inferencemodel_viewer_role.yaml -- inferencepool_editor_role.yaml -- inferencepool_viewer_role.yaml - diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml deleted file mode 100644 index e2f8551b..00000000 --- a/config/rbac/leader_election_role.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# permissions to do leader election. -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: leader-election-role -rules: -- apiGroups: - - "" - resources: - - configmaps - verbs: - - get - - list - - watch - - create - - update - - patch - - delete -- apiGroups: - - coordination.k8s.io - resources: - - leases - verbs: - - get - - list - - watch - - create - - update - - patch - - delete -- apiGroups: - - "" - resources: - - events - verbs: - - create - - patch diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml deleted file mode 100644 index fb71a122..00000000 --- a/config/rbac/leader_election_role_binding.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: leader-election-rolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: leader-election-role -subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system diff --git a/config/rbac/metrics_auth_role.yaml b/config/rbac/metrics_auth_role.yaml deleted file mode 100644 index 32d2e4ec..00000000 --- a/config/rbac/metrics_auth_role.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: metrics-auth-role -rules: -- apiGroups: - - authentication.k8s.io - resources: - - tokenreviews - verbs: - - create -- apiGroups: - - authorization.k8s.io - resources: - - subjectaccessreviews - verbs: - - create diff --git a/config/rbac/metrics_auth_role_binding.yaml b/config/rbac/metrics_auth_role_binding.yaml deleted file mode 100644 index e775d67f..00000000 --- a/config/rbac/metrics_auth_role_binding.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: metrics-auth-rolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: metrics-auth-role -subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system diff --git a/config/rbac/metrics_reader_role.yaml b/config/rbac/metrics_reader_role.yaml deleted file mode 100644 index 51a75db4..00000000 --- a/config/rbac/metrics_reader_role.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: metrics-reader -rules: -- nonResourceURLs: - - "/metrics" - verbs: - - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml deleted file mode 100644 index 9d6247eb..00000000 --- a/config/rbac/role.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: manager-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml deleted file mode 100644 index c66b66bf..00000000 --- a/config/rbac/role_binding.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: manager-rolebinding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: manager-role -subjects: -- kind: ServiceAccount - name: controller-manager - namespace: system diff --git a/config/rbac/service_account.yaml b/config/rbac/service_account.yaml deleted file mode 100644 index 9286120f..00000000 --- a/config/rbac/service_account.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: controller-manager - namespace: system diff --git a/config/samples/gateway_v1alpha1_inferencemodel.yaml b/config/samples/gateway_v1alpha1_inferencemodel.yaml deleted file mode 100644 index 34ea0680..00000000 --- a/config/samples/gateway_v1alpha1_inferencemodel.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: InferenceModel -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: sample-sql-assist -spec: - criticality: Critical - modelName: sql-code-assist - poolRef: - name: vllm-llama-31-8b-sample-pool - targetModels: - - name: npc-bot-v1 - weight: 50 - - name: npc-bot-v2 - weight: 50 diff --git a/config/samples/gateway_v1alpha1_inferencepool.yaml b/config/samples/gateway_v1alpha1_inferencepool.yaml deleted file mode 100644 index 4993d786..00000000 --- a/config/samples/gateway_v1alpha1_inferencepool.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: InferencePool -metadata: - labels: - app.kubernetes.io/name: api - app.kubernetes.io/managed-by: kustomize - name: vllm-llama-31-8b-sample-pool -spec: - selector: - app: npc-bot - targetPortNumber: 8000 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml deleted file mode 100644 index e4b9f2e8..00000000 --- a/config/samples/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -## Append samples of your project ## -resources: -- gateway_v1alpha1_inferencepool.yaml -- gateway_v1alpha1_inferencemodel.yaml -# +kubebuilder:scaffold:manifestskustomizesamples From c0b3dbdb4b892c4bafdc08fcea26ae4ab14aaf99 Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Tue, 8 Apr 2025 13:12:43 -0400 Subject: [PATCH 210/260] Rename pkg/body-based-routing to pkg/bbr (#664) --- cmd/{body-based-routing => bbr}/health.go | 0 cmd/{body-based-routing => bbr}/main.go | 2 +- pkg/{body-based-routing => bbr}/README.md | 0 pkg/{body-based-routing => bbr}/handlers/request.go | 2 +- pkg/{body-based-routing => bbr}/handlers/request_test.go | 2 +- pkg/{body-based-routing => bbr}/handlers/response.go | 0 pkg/{body-based-routing => bbr}/handlers/server.go | 0 pkg/{body-based-routing => bbr}/handlers/server_test.go | 0 pkg/{body-based-routing => bbr}/metrics/metrics.go | 0 pkg/{body-based-routing => bbr}/server/runserver.go | 2 +- test/integration/bbr/hermetic_test.go | 2 +- 11 files changed, 5 insertions(+), 5 deletions(-) rename cmd/{body-based-routing => bbr}/health.go (100%) rename cmd/{body-based-routing => bbr}/main.go (98%) rename pkg/{body-based-routing => bbr}/README.md (100%) rename pkg/{body-based-routing => bbr}/handlers/request.go (98%) rename pkg/{body-based-routing => bbr}/handlers/request_test.go (98%) rename pkg/{body-based-routing => bbr}/handlers/response.go (100%) rename pkg/{body-based-routing => bbr}/handlers/server.go (100%) rename pkg/{body-based-routing => bbr}/handlers/server_test.go (100%) rename pkg/{body-based-routing => bbr}/metrics/metrics.go (100%) rename pkg/{body-based-routing => bbr}/server/runserver.go (96%) diff --git a/cmd/body-based-routing/health.go b/cmd/bbr/health.go similarity index 100% rename from cmd/body-based-routing/health.go rename to cmd/bbr/health.go diff --git a/cmd/body-based-routing/main.go b/cmd/bbr/main.go similarity index 98% rename from cmd/body-based-routing/main.go rename to cmd/bbr/main.go index cfc584ce..84b1fffa 100644 --- a/cmd/body-based-routing/main.go +++ b/cmd/bbr/main.go @@ -36,7 +36,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/server" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) diff --git a/pkg/body-based-routing/README.md b/pkg/bbr/README.md similarity index 100% rename from pkg/body-based-routing/README.md rename to pkg/bbr/README.md diff --git a/pkg/body-based-routing/handlers/request.go b/pkg/bbr/handlers/request.go similarity index 98% rename from pkg/body-based-routing/handlers/request.go rename to pkg/bbr/handlers/request.go index c0be46ac..32fffc02 100644 --- a/pkg/body-based-routing/handlers/request.go +++ b/pkg/bbr/handlers/request.go @@ -25,7 +25,7 @@ import ( eppb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) diff --git a/pkg/body-based-routing/handlers/request_test.go b/pkg/bbr/handlers/request_test.go similarity index 98% rename from pkg/body-based-routing/handlers/request_test.go rename to pkg/bbr/handlers/request_test.go index 0f088702..55c42a21 100644 --- a/pkg/body-based-routing/handlers/request_test.go +++ b/pkg/bbr/handlers/request_test.go @@ -28,7 +28,7 @@ import ( "google.golang.org/protobuf/testing/protocmp" "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" - "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) diff --git a/pkg/body-based-routing/handlers/response.go b/pkg/bbr/handlers/response.go similarity index 100% rename from pkg/body-based-routing/handlers/response.go rename to pkg/bbr/handlers/response.go diff --git a/pkg/body-based-routing/handlers/server.go b/pkg/bbr/handlers/server.go similarity index 100% rename from pkg/body-based-routing/handlers/server.go rename to pkg/bbr/handlers/server.go diff --git a/pkg/body-based-routing/handlers/server_test.go b/pkg/bbr/handlers/server_test.go similarity index 100% rename from pkg/body-based-routing/handlers/server_test.go rename to pkg/bbr/handlers/server_test.go diff --git a/pkg/body-based-routing/metrics/metrics.go b/pkg/bbr/metrics/metrics.go similarity index 100% rename from pkg/body-based-routing/metrics/metrics.go rename to pkg/bbr/metrics/metrics.go diff --git a/pkg/body-based-routing/server/runserver.go b/pkg/bbr/server/runserver.go similarity index 96% rename from pkg/body-based-routing/server/runserver.go rename to pkg/bbr/server/runserver.go index 1646aa5a..2001b7ff 100644 --- a/pkg/body-based-routing/server/runserver.go +++ b/pkg/bbr/server/runserver.go @@ -27,7 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" tlsutil "sigs.k8s.io/gateway-api-inference-extension/internal/tls" - "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/handlers" + "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/handlers" ) // ExtProcServerRunner provides methods to manage an external process server. diff --git a/test/integration/bbr/hermetic_test.go b/test/integration/bbr/hermetic_test.go index 02d412ab..b99186db 100644 --- a/test/integration/bbr/hermetic_test.go +++ b/test/integration/bbr/hermetic_test.go @@ -29,7 +29,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/testing/protocmp" - runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/body-based-routing/server" + runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" integrationutils "sigs.k8s.io/gateway-api-inference-extension/test/integration" ) From 59c5781070496646cadabdbbefef66210577b094 Mon Sep 17 00:00:00 2001 From: Clayton Coleman Date: Tue, 8 Apr 2025 13:48:42 -0400 Subject: [PATCH 211/260] deploy: Enable logging for GKE gateway by default (#666) Logging dramatically reduces initial friction debugging and relative to the cost to serve is fairly minor (about 2-5% overhead). Enable by default as consistent with our guides. --- config/charts/inferencepool/templates/gke.yaml | 2 ++ config/manifests/gateway/gke/gcp-backend-policy.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/config/charts/inferencepool/templates/gke.yaml b/config/charts/inferencepool/templates/gke.yaml index 220b3bea..70e05b56 100644 --- a/config/charts/inferencepool/templates/gke.yaml +++ b/config/charts/inferencepool/templates/gke.yaml @@ -33,6 +33,8 @@ spec: name: {{ .Release.Name }} default: timeoutSec: 300 # 5-minute timeout (adjust as needed) + logging: + enabled: true # log all requests by default --- apiVersion: monitoring.googleapis.com/v1 kind: ClusterPodMonitoring diff --git a/config/manifests/gateway/gke/gcp-backend-policy.yaml b/config/manifests/gateway/gke/gcp-backend-policy.yaml index 519a5a93..7b294304 100644 --- a/config/manifests/gateway/gke/gcp-backend-policy.yaml +++ b/config/manifests/gateway/gke/gcp-backend-policy.yaml @@ -9,3 +9,5 @@ spec: name: vllm-llama3-8b-instruct default: timeoutSec: 300 + logging: + enabled: true From 3690dbe97b9572c7751ff88b524290dab9f8055e Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 8 Apr 2025 21:06:45 +0300 Subject: [PATCH 212/260] moved IsPodReady func to podutils (#662) * moved IsPodReady func to pod utils to be shared between pod reconciler and datastore Signed-off-by: Nir Rozenbaum * code review changes Signed-off-by: Nir Rozenbaum * plural to singular Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/controller/pod_reconciler.go | 15 ++----------- pkg/epp/datastore/datastore.go | 16 ++------------ pkg/epp/util/pod/pod.go | 33 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 27 deletions(-) create mode 100644 pkg/epp/util/pod/pod.go diff --git a/pkg/epp/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go index 046561e4..494adeb7 100644 --- a/pkg/epp/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + podutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/pod" ) type PodReconciler struct { @@ -71,7 +72,7 @@ func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod, pool *v1alpha2.InferencePool) { namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} - if !pod.DeletionTimestamp.IsZero() || !c.Datastore.PoolLabelsMatch(pod.Labels) || !podIsReady(pod) { + if !pod.DeletionTimestamp.IsZero() || !c.Datastore.PoolLabelsMatch(pod.Labels) || !podutil.IsPodReady(pod) { logger.V(logutil.DEBUG).Info("Pod removed or not added", "name", namespacedName) c.Datastore.PodDelete(namespacedName) } else { @@ -82,15 +83,3 @@ func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod, poo } } } - -func podIsReady(pod *corev1.Pod) bool { - for _, condition := range pod.Status.Conditions { - if condition.Type == corev1.PodReady { - if condition.Status == corev1.ConditionTrue { - return true - } - break - } - } - return false -} diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index 8ada3e64..dc81cb48 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + podutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/pod" ) const ( @@ -259,7 +260,7 @@ func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client, activePods := make(map[string]bool) for _, pod := range podList.Items { - if podIsReady(&pod) { + if podutil.IsPodReady(&pod) { namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} activePods[pod.Name] = true if ds.PodUpdateOrAddIfNotExist(&pod, pool) { @@ -308,16 +309,3 @@ func IsCritical(model *v1alpha2.InferenceModel) bool { } return false } - -// TODO: move out to share with pod_reconciler.go -func podIsReady(pod *corev1.Pod) bool { - for _, condition := range pod.Status.Conditions { - if condition.Type == corev1.PodReady { - if condition.Status == corev1.ConditionTrue { - return true - } - break - } - } - return false -} diff --git a/pkg/epp/util/pod/pod.go b/pkg/epp/util/pod/pod.go new file mode 100644 index 00000000..9f564024 --- /dev/null +++ b/pkg/epp/util/pod/pod.go @@ -0,0 +1,33 @@ +/* +Copyright 2025 The Kubernetes 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 pod + +import ( + corev1 "k8s.io/api/core/v1" +) + +func IsPodReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + if condition.Status == corev1.ConditionTrue { + return true + } + break + } + } + return false +} From e71fd9281b3c1958e8bccde4536851fbce0f04ab Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 8 Apr 2025 22:46:50 +0300 Subject: [PATCH 213/260] removed double loop on docs in hermetic test (#668) use unstructured instead of checking InferenceModel/InferencePool and unmarshalling to specific object Signed-off-by: Nir Rozenbaum --- test/integration/epp/hermetic_test.go | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 93432637..ae2c6170 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -44,6 +44,7 @@ import ( "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -1691,27 +1692,13 @@ func BeforeSuite() func() { } for _, doc := range docs { - inferenceModel := &v1alpha2.InferenceModel{} - if err = yaml.Unmarshal(doc, inferenceModel); err != nil { + obj := &unstructured.Unstructured{} + if err = yaml.Unmarshal(doc, obj); err != nil { logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) } - if inferenceModel.Kind == "InferenceModel" { - logger.Info("Creating inference model", "model", inferenceModel) - if err := k8sClient.Create(context.Background(), inferenceModel); err != nil { - logutil.Fatal(logger, err, "Unable to create inferenceModel", "modelName", inferenceModel.Name) - } - } - } - for _, doc := range docs { - inferencePool := &v1alpha2.InferencePool{} - if err = yaml.Unmarshal(doc, inferencePool); err != nil { - logutil.Fatal(logger, err, "Can't unmarshal object", "document", doc) - } - if inferencePool.Kind == "InferencePool" { - logger.Info("Creating inference pool", "pool", inferencePool) - if err := k8sClient.Create(context.Background(), inferencePool); err != nil { - logutil.Fatal(logger, err, "Unable to create inferencePool", "poolName", inferencePool.Name) - } + logger.Info("Creating object", "kind", obj.GetKind(), "object", obj) + if err := k8sClient.Create(context.Background(), obj); err != nil { + logutil.Fatal(logger, err, "Unable to create object", "object", obj.GetName()) } } From 4ed93bfe1971271936de26b547f126cf9c2e329e Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 8 Apr 2025 23:36:57 +0300 Subject: [PATCH 214/260] fix bbr dockerfile that was broken in PR #664 (#669) * fixed dockerfile of bbr that was broken in PR #664 Signed-off-by: Nir Rozenbaum * code review Signed-off-by: Nir Rozenbaum * makefile Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- Makefile | 2 +- body-based-routing.Dockerfile => bbr.Dockerfile | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename body-based-routing.Dockerfile => bbr.Dockerfile (76%) diff --git a/Makefile b/Makefile index 66fe89d4..a1845560 100644 --- a/Makefile +++ b/Makefile @@ -232,7 +232,7 @@ bbr-image-local-load: bbr-image-local-build .PHONY: bbr-image-build bbr-image-build: ## Build the image using Docker Buildx. - $(IMAGE_BUILD_CMD) -f body-based-routing.Dockerfile -t $(BBR_IMAGE_TAG) \ + $(IMAGE_BUILD_CMD) -f bbr.Dockerfile -t $(BBR_IMAGE_TAG) \ --platform=$(PLATFORMS) \ --build-arg BASE_IMAGE=$(BASE_IMAGE) \ --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ diff --git a/body-based-routing.Dockerfile b/bbr.Dockerfile similarity index 76% rename from body-based-routing.Dockerfile rename to bbr.Dockerfile index e0afcf20..03024e49 100644 --- a/body-based-routing.Dockerfile +++ b/bbr.Dockerfile @@ -18,13 +18,13 @@ RUN go mod download COPY cmd ./cmd COPY pkg ./pkg COPY internal ./internal -WORKDIR /src/cmd/body-based-routing -RUN go build -o /body-based-routing +WORKDIR /src/cmd/bbr +RUN go build -o /bbr ## Multistage deploy FROM ${BASE_IMAGE} WORKDIR / -COPY --from=builder /body-based-routing /body-based-routing +COPY --from=builder /bbr /bbr -ENTRYPOINT ["/body-based-routing"] +ENTRYPOINT ["/bbr"] From ae3df874157b91c1858ff7c378896416b3412b1a Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Tue, 8 Apr 2025 18:20:50 -0400 Subject: [PATCH 215/260] E2E test improvements (#661) --- config/manifests/inferencepool-resources.yaml | 3 + config/manifests/vllm/cpu-deployment.yaml | 5 +- test/e2e/epp/README.md | 7 + test/e2e/epp/e2e_suite_test.go | 46 ++++++- test/testdata/envoy.yaml | 6 +- test/testdata/inferencepool-e2e.yaml | 126 ++++++++++++++++++ 6 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 test/testdata/inferencepool-e2e.yaml diff --git a/config/manifests/inferencepool-resources.yaml b/config/manifests/inferencepool-resources.yaml index cef70d7f..4affa274 100644 --- a/config/manifests/inferencepool-resources.yaml +++ b/config/manifests/inferencepool-resources.yaml @@ -1,3 +1,6 @@ +# Note: If you change this file, please also change the file used for e2e tests! +# +# https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/test/testdata/inferencepool-e2e.yaml apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml index 6fb40950..827f2156 100644 --- a/config/manifests/vllm/cpu-deployment.yaml +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -113,5 +113,8 @@ data: ensureExist: models: - base-model: Qwen/Qwen2.5-1.5B - id: food-review-1 + id: food-review + source: SriSanth2345/Qwen-1.5B-Tweet-Generations + - base-model: Qwen/Qwen2.5-1.5B + id: cad-fabricator source: SriSanth2345/Qwen-1.5B-Tweet-Generations \ No newline at end of file diff --git a/test/e2e/epp/README.md b/test/e2e/epp/README.md index 247e8b12..fcc974b8 100644 --- a/test/e2e/epp/README.md +++ b/test/e2e/epp/README.md @@ -28,6 +28,13 @@ Follow these steps to run the end-to-end tests: export HF_TOKEN= ``` +1. **(Optional): Set the test namespace**: By default, the e2e test creates resources in the `inf-ext-e2e` namespace. + If you would like to change this namespace, set the following environment variable: + + ```sh + export E2E_NS= + ``` + 1. **Run the Tests**: Run the `test-e2e` target: ```sh diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index 61ee2540..01ed639d 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -30,6 +30,7 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -55,9 +56,8 @@ const ( defaultInterval = time.Millisecond * 250 // defaultCurlInterval is the default interval to run the test curl command. defaultCurlInterval = time.Second * 5 - // nsName is the name of the Namespace used for tests. - // TODO [danehans]: Must be "default" until https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/227 is fixed - nsName = "default" + // defaultNsName is the default name of the Namespace used for tests. Can override using the E2E_NS environment variable. + defaultNsName = "inf-ext-e2e" // modelServerName is the name of the model server test resources. modelServerName = "vllm-llama3-8b-instruct" // modelName is the test model name. @@ -77,7 +77,7 @@ const ( // inferModelManifest is the manifest for the inference model CRD. inferModelManifest = "../../../config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml" // inferExtManifest is the manifest for the inference extension test resources. - inferExtManifest = "../../../config/manifests/inferencepool-resources.yaml" + inferExtManifest = "../../testdata/inferencepool-e2e.yaml" // envoyManifest is the manifest for the envoy proxy test resources. envoyManifest = "../../testdata/envoy.yaml" // modelServerManifestFilepathEnvVar is the env var that holds absolute path to the manifest for the model server test resource. @@ -91,6 +91,7 @@ var ( kubeCli *kubernetes.Clientset scheme = runtime.NewScheme() cfg = config.GetConfigOrDie() + nsName string ) func TestAPIs(t *testing.T) { @@ -101,6 +102,11 @@ func TestAPIs(t *testing.T) { } var _ = ginkgo.BeforeSuite(func() { + nsName = os.Getenv("E2E_NS") + if nsName == "" { + nsName = defaultNsName + } + ginkgo.By("Setting up the test suite") setupSuite() @@ -109,6 +115,8 @@ var _ = ginkgo.BeforeSuite(func() { }) func setupInfra() { + createNamespace(cli, nsName) + modelServerManifestPath := readModelServerManifestPath() modelServerManifestArray := getYamlsFromModelServerManifest(modelServerManifestPath) if strings.Contains(modelServerManifestArray[0], "hf-token") { @@ -118,6 +126,7 @@ func setupInfra() { "inferencepools.inference.networking.x-k8s.io": inferPoolManifest, "inferencemodels.inference.networking.x-k8s.io": inferModelManifest, } + createCRDs(cli, crds) createInferExt(cli, inferExtManifest) createClient(cli, clientManifest) @@ -182,6 +191,17 @@ var ( curlInterval = defaultCurlInterval ) +func createNamespace(k8sClient client.Client, ns string) { + ginkgo.By("Creating e2e namespace: " + ns) + obj := &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{ + Name: ns, + }, + } + err := k8sClient.Create(ctx, obj) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to create e2e test namespace") +} + // namespaceExists ensures that a specified namespace exists and is ready for use. func namespaceExists(k8sClient client.Client, ns string) { ginkgo.By("Ensuring namespace exists: " + ns) @@ -276,8 +296,15 @@ func createHfSecret(k8sClient client.Client, secretPath string) { // createEnvoy creates the envoy proxy resources used for testing from the given filePath. func createEnvoy(k8sClient client.Client, filePath string) { + inManifests := readYaml(filePath) + ginkgo.By("Replacing placeholder namespace with E2E_NS environment variable") + outManifests := []string{} + for _, m := range inManifests { + outManifests = append(outManifests, strings.ReplaceAll(m, "$E2E_NS", nsName)) + } + ginkgo.By("Creating envoy proxy resources from manifest: " + filePath) - applyYAMLFile(k8sClient, filePath) + createObjsFromYaml(k8sClient, outManifests) // Wait for the configmap to exist before proceeding with test. cfgMap := &corev1.ConfigMap{} @@ -302,8 +329,15 @@ func createEnvoy(k8sClient client.Client, filePath string) { // createInferExt creates the inference extension resources used for testing from the given filePath. func createInferExt(k8sClient client.Client, filePath string) { + inManifests := readYaml(filePath) + ginkgo.By("Replacing placeholder namespace with E2E_NS environment variable") + outManifests := []string{} + for _, m := range inManifests { + outManifests = append(outManifests, strings.ReplaceAll(m, "$E2E_NS", nsName)) + } + ginkgo.By("Creating inference extension resources from manifest: " + filePath) - applyYAMLFile(k8sClient, filePath) + createObjsFromYaml(k8sClient, outManifests) // Wait for the clusterrole to exist. testutils.EventuallyExists(ctx, func() error { diff --git a/test/testdata/envoy.yaml b/test/testdata/envoy.yaml index 62e6b4c5..3fff8598 100644 --- a/test/testdata/envoy.yaml +++ b/test/testdata/envoy.yaml @@ -100,7 +100,7 @@ data: grpc_service: envoy_grpc: cluster_name: ext_proc - authority: vllm-llama3-8b-instruct-epp.default:9002 + authority: vllm-llama3-8b-instruct-epp.$E2E_NS:9002 timeout: 10s processing_mode: request_header_mode: SEND @@ -195,7 +195,7 @@ data: - endpoint: address: socket_address: - address: vllm-llama3-8b-instruct-epp.default + address: vllm-llama3-8b-instruct-epp.$E2E_NS port_value: 9002 health_status: HEALTHY load_balancing_weight: 1 @@ -225,7 +225,7 @@ spec: image: docker.io/envoyproxy/envoy:distroless-v1.33.2 args: - "--service-cluster" - - "default/inference-gateway" + - "$E2E_NS/inference-gateway" - "--service-node" - "$(ENVOY_POD_NAME)" - "--log-level" diff --git a/test/testdata/inferencepool-e2e.yaml b/test/testdata/inferencepool-e2e.yaml new file mode 100644 index 00000000..79339c5b --- /dev/null +++ b/test/testdata/inferencepool-e2e.yaml @@ -0,0 +1,126 @@ +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + labels: + name: vllm-llama3-8b-instruct +spec: + targetPortNumber: 8000 + selector: + app: vllm-llama3-8b-instruct + extensionRef: + name: vllm-llama3-8b-instruct-epp + namespace: $E2E_NS +--- +apiVersion: v1 +kind: Service +metadata: + name: vllm-llama3-8b-instruct-epp + namespace: $E2E_NS +spec: + selector: + app: vllm-llama3-8b-instruct-epp + ports: + - protocol: TCP + port: 9002 + targetPort: 9002 + appProtocol: http2 + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vllm-llama3-8b-instruct-epp + namespace: $E2E_NS + labels: + app: vllm-llama3-8b-instruct-epp +spec: + replicas: 1 + selector: + matchLabels: + app: vllm-llama3-8b-instruct-epp + template: + metadata: + labels: + app: vllm-llama3-8b-instruct-epp + spec: + # Conservatively, this timeout should mirror the longest grace period of the pods within the pool + terminationGracePeriodSeconds: 130 + containers: + - name: epp + image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/epp:main + imagePullPolicy: Always + args: + - -poolName + - "vllm-llama3-8b-instruct" + - -poolNamespace + - "$E2E_NS" + - -v + - "4" + - --zap-encoder + - "json" + - -grpcPort + - "9002" + - -grpcHealthPort + - "9003" + env: + - name: USE_STREAMING + value: "true" + ports: + - containerPort: 9002 + - containerPort: 9003 + - name: metrics + containerPort: 9090 + livenessProbe: + grpc: + port: 9003 + service: inference-extension + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + grpc: + port: 9003 + service: inference-extension + initialDelaySeconds: 5 + periodSeconds: 10 +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: pod-read +rules: +- apiGroups: ["inference.networking.x-k8s.io"] + resources: ["inferencemodels"] + verbs: ["get", "watch", "list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "watch", "list"] +- apiGroups: ["inference.networking.x-k8s.io"] + resources: ["inferencepools"] + verbs: ["get", "watch", "list"] +- apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "watch", "list"] +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: pod-read-binding +subjects: +- kind: ServiceAccount + name: default + namespace: $E2E_NS +roleRef: + kind: ClusterRole + name: pod-read From 42eb5ff1c5af1275df43ac384df0ddf20da95134 Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Tue, 8 Apr 2025 22:20:56 +0000 Subject: [PATCH 216/260] cleaning up inferencePool helm docs (#665) --- config/charts/inferencepool/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md index 681fc783..e5468cd4 100644 --- a/config/charts/inferencepool/README.md +++ b/config/charts/inferencepool/README.md @@ -17,9 +17,12 @@ To install via the latest published chart in staging (--version v0 indicates la ```txt $ helm install vllm-llama3-8b-instruct \ --set inferencePool.modelServers.matchLabels.app=vllm-llama3-8b-instruct \ + --set provider.name=[none|gke] \ oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0 ``` +Note that the provider name is needed to deploy provider-specific resources. If no provider is specified, then only the InferencePool object and the EPP are deployed. + ## Uninstall Run the following command to uninstall the chart: @@ -34,7 +37,6 @@ The following table list the configurable parameters of the chart. | **Parameter Name** | **Description** | |---------------------------------------------|------------------------------------------------------------------------------------------------------------------------| -| `inferencePool.name` | Name for the InferencePool, and endpoint picker deployment and service will be named as `{.Release.name}-epp`. | | `inferencePool.targetPortNumber` | Target port number for the vllm backends, will be used to scrape metrics by the inference extension. Defaults to 8000. | | `inferencePool.modelServers.matchLabels` | Label selector to match vllm backends managed by the inference pool. | | `inferenceExtension.replicas` | Number of replicas for the endpoint picker extension service. Defaults to `1`. | @@ -43,6 +45,7 @@ The following table list the configurable parameters of the chart. | `inferenceExtension.image.tag` | Image tag of the endpoint picker. | | `inferenceExtension.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | | `inferenceExtension.extProcPort` | Port where the endpoint picker service is served for external processing. Defaults to `9002`. | +| `provider.name` | Name of the Inference Gateway implementation being used. Possible values: `gke`. Defaults to `none`. | ## Notes From 4761c71b91b3da754fdc66264c64cd56eb85c1f9 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 9 Apr 2025 19:46:40 +0300 Subject: [PATCH 217/260] move inf model IsCritial func out of datastore (#670) * move inf model IsCritial func out of datastore Signed-off-by: Nir Rozenbaum * remove IsCritical function helper function Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/datastore/datastore.go | 9 +-------- pkg/epp/handlers/request.go | 4 ++-- pkg/epp/handlers/streamingserver.go | 2 +- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index dc81cb48..5435e3af 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -69,7 +69,7 @@ type Datastore interface { Clear() } -func NewDatastore(parentCtx context.Context, pmf *backendmetrics.PodMetricsFactory) *datastore { +func NewDatastore(parentCtx context.Context, pmf *backendmetrics.PodMetricsFactory) Datastore { store := &datastore{ parentCtx: parentCtx, poolAndModelsMu: sync.RWMutex{}, @@ -302,10 +302,3 @@ func stripLabelKeyAliasFromLabelMap(labels map[v1alpha2.LabelKey]v1alpha2.LabelV } return outMap } - -func IsCritical(model *v1alpha2.InferenceModel) bool { - if model.Spec.Criticality != nil && *model.Spec.Criticality == v1alpha2.Critical { - return true - } - return false -} diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index b786a15d..e8dcf262 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -26,7 +26,7 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -77,7 +77,7 @@ func (s *Server) HandleRequestBody( llmReq := &schedulingtypes.LLMRequest{ Model: model, ResolvedTargetModel: modelName, - Critical: datastore.IsCritical(modelObj), + Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, } loggerVerbose.Info("LLM request assembled", "request", llmReq) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 88963f47..ca3451cb 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -348,7 +348,7 @@ func (s *StreamingServer) HandleRequestBody( llmReq := &schedulingtypes.LLMRequest{ Model: model, ResolvedTargetModel: modelName, - Critical: datastore.IsCritical(modelObj), + Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, } logger.V(logutil.DEBUG).Info("LLM request assembled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "critical", llmReq.Critical) From 1ba13f390d17709ed825d9c952a8117e4f0df24e Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 9 Apr 2025 16:42:41 -0700 Subject: [PATCH 218/260] Consolidating down to FULL_DUPLEX_STREAMED supported ext-proc server (#672) --- cmd/epp/main.go | 6 - .../templates/epp-deployment.yaml | 3 - config/manifests/inferencepool-resources.yaml | 3 - pkg/epp/handlers/request.go | 162 ++--- pkg/epp/handlers/response.go | 216 ++----- pkg/epp/handlers/response_test.go | 79 ++- pkg/epp/handlers/server.go | 435 ++++++++++--- pkg/epp/handlers/streamingserver.go | 594 ------------------ pkg/epp/server/runserver.go | 9 +- test/integration/epp/hermetic_test.go | 319 ---------- 10 files changed, 490 insertions(+), 1336 deletions(-) delete mode 100644 pkg/epp/handlers/streamingserver.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 39baf18b..b9c7d6e4 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -120,11 +120,6 @@ func run() error { flag.Parse() initLogging(&opts) - useStreamingServer, err := strconv.ParseBool(os.Getenv("USE_STREAMING")) - if err != nil { - setupLog.Error(err, "Failed to parse env var USE_STREAMING, defaulting to false") - } - // Validate flags if err := validateFlags(); err != nil { setupLog.Error(err, "Failed to validate flags") @@ -178,7 +173,6 @@ func run() error { Datastore: datastore, SecureServing: *secureServing, CertPath: *certPath, - UseStreaming: useStreamingServer, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, } if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { diff --git a/config/charts/inferencepool/templates/epp-deployment.yaml b/config/charts/inferencepool/templates/epp-deployment.yaml index d925a38e..0b9fa0bd 100644 --- a/config/charts/inferencepool/templates/epp-deployment.yaml +++ b/config/charts/inferencepool/templates/epp-deployment.yaml @@ -35,9 +35,6 @@ spec: - "9003" - -metricsPort - "9090" - env: - - name: USE_STREAMING - value: "true" ports: - name: grpc containerPort: 9002 diff --git a/config/manifests/inferencepool-resources.yaml b/config/manifests/inferencepool-resources.yaml index 4affa274..993b7bf6 100644 --- a/config/manifests/inferencepool-resources.yaml +++ b/config/manifests/inferencepool-resources.yaml @@ -62,9 +62,6 @@ spec: - "9002" - -grpcHealthPort - "9003" - env: - - name: USE_STREAMING - value: "true" ports: - containerPort: 9002 - containerPort: 9003 diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index e8dcf262..44537923 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -21,10 +21,9 @@ import ( "encoding/json" "fmt" "strconv" + "time" - configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" @@ -32,33 +31,22 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -// HandleRequestBody handles body of the request to the backend server, such as parsing the "model" -// parameter. -// Envoy sends the request body to ext proc before sending the request to the backend server. -func (s *Server) HandleRequestBody( +// HandleRequestBody always returns the requestContext even in the error case, as the request context is used in error handling. +func (s *StreamingServer) HandleRequestBody( ctx context.Context, reqCtx *RequestContext, req *extProcPb.ProcessingRequest, -) (*extProcPb.ProcessingResponse, error) { + requestBodyMap map[string]interface{}, +) (*RequestContext, error) { + var requestBodyBytes []byte logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Handling request body") - - // Unmarshal request body (must be JSON). - v := req.Request.(*extProcPb.ProcessingRequest_RequestBody) - var rb map[string]interface{} - if err := json.Unmarshal(v.RequestBody.Body, &rb); err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") - return nil, errutil.Error{Code: errutil.BadRequest, Msg: fmt.Sprintf("error unmarshaling request body: %v", err)} - } - loggerVerbose.Info("Request body unmarshalled", "body", rb) // Resolve target models. - model, ok := rb["model"].(string) + model, ok := requestBodyMap["model"].(string) if !ok { - return nil, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} + return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} } - loggerVerbose.Info("Model requested", "model", model) + modelName := model // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. @@ -66,12 +54,12 @@ func (s *Server) HandleRequestBody( // are able to be requested by using their distinct name. modelObj := s.datastore.ModelGet(model) if modelObj == nil { - return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} + return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} } if len(modelObj.Spec.TargetModels) > 0 { modelName = RandomWeightedDraw(logger, modelObj, 0) if modelName == "" { - return nil, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} + return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } } llmReq := &schedulingtypes.LLMRequest{ @@ -79,132 +67,84 @@ func (s *Server) HandleRequestBody( ResolvedTargetModel: modelName, Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, } - loggerVerbose.Info("LLM request assembled", "request", llmReq) + logger.V(logutil.DEBUG).Info("LLM request assembled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "critical", llmReq.Critical) - requestBody := v.RequestBody.Body var err error // Update target models in the body. if llmReq.Model != llmReq.ResolvedTargetModel { - rb["model"] = llmReq.ResolvedTargetModel - requestBody, err = json.Marshal(rb) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") - return nil, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} - } - loggerVerbose.Info("Updated request body marshalled", "body", string(requestBody)) + requestBodyMap["model"] = llmReq.ResolvedTargetModel + } + + requestBodyBytes, err = json.Marshal(requestBodyMap) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") + return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} } target, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { - return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} + return reqCtx, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} } targetPod := target.GetPod() - logger.V(logutil.DEFAULT).Info("Request handled", - "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) - // Insert target endpoint to instruct Envoy to route requests to the specified target pod. // Attach the port number pool, err := s.datastore.PoolGet() if err != nil { - return nil, err + return reqCtx, err } endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) + logger.V(logutil.DEFAULT).Info("Request handled", + "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod, "endpoint metrics", + fmt.Sprintf("%+v", target)) + reqCtx.Model = llmReq.Model reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel - reqCtx.RequestSize = len(v.RequestBody.Body) + reqCtx.RequestSize = len(requestBodyBytes) reqCtx.TargetPod = targetPod.NamespacedName.String() reqCtx.TargetEndpoint = endpoint - headers := []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: s.destinationEndpointHintKey, - RawValue: []byte(endpoint), - }, - }, - // We need to update the content length header if the body is mutated, see Envoy doc: - // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto - { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte(strconv.Itoa(len(requestBody))), - }, - }, - } - // Print headers for debugging - for _, header := range headers { - logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) - } - - targetEndpointValue := &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.destinationEndpointHintKey: { - Kind: &structpb.Value_StringValue{ - StringValue: endpoint, - }, - }, - }, - } - dynamicMetadata := targetEndpointValue - if s.destinationEndpointHintMetadataNamespace != "" { - // If a namespace is defined, wrap the selected endpoint with that. - dynamicMetadata = &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.destinationEndpointHintMetadataNamespace: { - Kind: &structpb.Value_StructValue{ - StructValue: targetEndpointValue, - }, - }, - }, - } - } + s.populateRequestHeaderResponse(reqCtx, endpoint, len(requestBodyBytes)) - resp := &extProcPb.ProcessingResponse{ + reqCtx.reqBodyResp = &extProcPb.ProcessingResponse{ // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header // and as an unstructure ext-proc response metadata key/value pair. This enables different integration // options for gateway providers. Response: &extProcPb.ProcessingResponse_RequestBody{ RequestBody: &extProcPb.BodyResponse{ Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: headers, - }, BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_Body{ - Body: requestBody, + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: requestBodyBytes, + EndOfStream: true, + }, }, }, }, }, }, - DynamicMetadata: dynamicMetadata, } - return resp, nil + return reqCtx, nil } -func HandleRequestHeaders( - ctx context.Context, - reqCtx *RequestContext, - req *extProcPb.ProcessingRequest, -) *extProcPb.ProcessingResponse { - r := req.Request - h := r.(*extProcPb.ProcessingRequest_RequestHeaders) - log.FromContext(ctx).V(logutil.VERBOSE).Info("Handling request headers", "headers", h) - - resp := &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestHeaders{ - RequestHeaders: &extProcPb.HeadersResponse{ - Response: &extProcPb.CommonResponse{ - // Set `clear_route_cache = true` to force Envoy to recompute the target cluster - // based on the new "target-pod" header. - // See https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor.proto#service-ext-proc-v3-commonresponse. - ClearRouteCache: true, - }, - }, - }, +func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *RequestContext, req *extProcPb.ProcessingRequest_RequestHeaders) error { + reqCtx.RequestReceivedTimestamp = time.Now() + + // an EoS in the request headers means this request has no body or trailers. + if req.RequestHeaders.EndOfStream { + // We will route this request to a random pod as this is assumed to just be a GET + // More context: https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/526 + // The above PR will address endpoint admission, but currently any request without a body will be + // routed to a random upstream pod. + pod := GetRandomPod(s.datastore) + pool, err := s.datastore.PoolGet() + if err != nil { + return err + } + endpoint := pod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) + s.populateRequestHeaderResponse(reqCtx, endpoint, 0) } - - return resp + return nil } diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index 991b7d16..04c7a5e9 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -19,14 +19,11 @@ package handlers import ( "context" "encoding/json" - "fmt" "strings" - configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -35,78 +32,48 @@ const ( streamingEndMsg = "data: [DONE]" ) -// HandleResponseHeaders processes response headers from the backend model server. -func (s *Server) HandleResponseHeaders( +// HandleResponseBody always returns the requestContext even in the error case, as the request context is used in error handling. +func (s *StreamingServer) HandleResponseBody( ctx context.Context, reqCtx *RequestContext, - req *extProcPb.ProcessingRequest, -) (*extProcPb.ProcessingResponse, error) { - loggerVerbose := log.FromContext(ctx).V(logutil.VERBOSE) - loggerVerbose.Info("Processing ResponseHeaders") - h := req.Request.(*extProcPb.ProcessingRequest_ResponseHeaders) - loggerVerbose.Info("Headers before", "headers", h) - - // Example header - // { - // "ResponseHeaders": { - // "headers": [ - // { - // "key": ":status", - // "raw_value": "200" - // }, - // { - // "key": "date", - // "raw_value": "Thu, 30 Jan 2025 18:50:48 GMT" - // }, - // { - // "key": "server", - // "raw_value": "uvicorn" - // }, - // { - // "key": "content-type", - // "raw_value": "text/event-stream; charset=utf-8" - // }, - // { - // "key": "transfer-encoding", - // "raw_value": "chunked" - // } - // ] - // } - // } - for _, header := range h.ResponseHeaders.Headers.GetHeaders() { - var statusFound, typeFound bool - if header.Key == "status" { - code := header.RawValue[0] - if string(code) != "200" { - reqCtx.ResponseStatusCode = errutil.ModelServerError - statusFound = true - } - } - if header.Key == "content-type" { - contentType := header.RawValue - if strings.Contains(string(contentType), "text/event-stream") { - reqCtx.modelServerStreaming = true - } - typeFound = true - } - - if statusFound && typeFound { - break + response map[string]interface{}, +) (*RequestContext, error) { + logger := log.FromContext(ctx) + responseBytes, err := json.Marshal(response) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "error marshalling responseBody") + return reqCtx, err + } + if response["usage"] != nil { + usg := response["usage"].(map[string]interface{}) + usage := Usage{ + PromptTokens: int(usg["prompt_tokens"].(float64)), + CompletionTokens: int(usg["completion_tokens"].(float64)), + TotalTokens: int(usg["total_tokens"].(float64)), } + reqCtx.Usage = usage + logger.V(logutil.VERBOSE).Info("Response generated", "usage", reqCtx.Usage) } + reqCtx.ResponseSize = len(responseBytes) + // ResponseComplete is to indicate the response is complete. In non-streaming + // case, it will be set to be true once the response is processed; in + // streaming case, it will be set to be true once the last chunk is processed. + // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) + // will add the processing for streaming case. + reqCtx.ResponseComplete = true - resp := &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseHeaders{ - ResponseHeaders: &extProcPb.HeadersResponse{ + reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ + // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header + // and as an unstructure ext-proc response metadata key/value pair. This enables different integration + // options for gateway providers. + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - // This is for debugging purpose only. - Key: "x-went-into-resp-headers", - RawValue: []byte("true"), - }, + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: responseBytes, + EndOfStream: true, }, }, }, @@ -114,106 +81,21 @@ func (s *Server) HandleResponseHeaders( }, }, } - return resp, nil + return reqCtx, nil } -// HandleResponseBody parses response body to update information such as number of completion tokens. -// NOTE: The current implementation only supports Buffered mode, which is not enabled by default. To -// use it, you need to configure EnvoyExtensionPolicy to have response body in Buffered mode. -// https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto#envoy-v3-api-msg-extensions-filters-http-ext-proc-v3-processingmode -// Example response -/* -{ - "id": "cmpl-573498d260f2423f9e42817bbba3743a", - "object": "text_completion", - "created": 1732563765, - "model": "meta-llama/Llama-3.1-8B-Instruct", - "choices": [ - { - "index": 0, - "text": " Chronicle\nThe San Francisco Chronicle has a new book review section, and it's a good one. The reviews are short, but they're well-written and well-informed. The Chronicle's book review section is a good place to start if you're looking for a good book review.\nThe Chronicle's book review section is a good place to start if you're looking for a good book review. The Chronicle's book review section", - "logprobs": null, - "finish_reason": "length", - "stop_reason": null, - "prompt_logprobs": null - } - ], - "usage": { - "prompt_tokens": 11, - "total_tokens": 111, - "completion_tokens": 100 - } -}*/ -func (s *Server) HandleResponseBody( +// The function is to handle streaming response if the modelServer is streaming. +func (s *StreamingServer) HandleResponseBodyModelStreaming( ctx context.Context, reqCtx *RequestContext, - req *extProcPb.ProcessingRequest, -) (*extProcPb.ProcessingResponse, error) { - logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - body := req.Request.(*extProcPb.ProcessingRequest_ResponseBody) - - if reqCtx.modelServerStreaming { - logger.V(logutil.DEBUG).Info("Processing HandleResponseBody") - if err := s.HandleStreaming(ctx, reqCtx, body, loggerVerbose); err != nil { - return nil, err - } - } else { - loggerVerbose.Info("Processing HandleResponseBody") - if err := s.HandleNonStreaming(ctx, reqCtx, body, loggerVerbose); err != nil { - return nil, err - } - } - - resp := &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseBody{ - ResponseBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{}, - }, - }, - } - return resp, nil -} - -func (s *Server) HandleNonStreaming( - ctx context.Context, - reqCtx *RequestContext, - body *extProcPb.ProcessingRequest_ResponseBody, - loggerVerbose logr.Logger, -) error { - loggerVerbose.Info("Processing HandleResponseBody") - - res := Response{} - if err := json.Unmarshal(body.ResponseBody.Body, &res); err != nil { - return errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("unmarshaling response body: %v", err)} - } - reqCtx.Usage = res.Usage - reqCtx.ResponseSize = len(body.ResponseBody.Body) - reqCtx.ResponseComplete = true - loggerVerbose.Info("Response generated", "response", res) - return nil -} - -func (s *Server) HandleStreaming( - ctx context.Context, - reqCtx *RequestContext, - body *extProcPb.ProcessingRequest_ResponseBody, - loggerVerbose logr.Logger, -) error { - responseText := string(body.ResponseBody.Body) + responseText string, +) { if strings.Contains(responseText, streamingEndMsg) { - parsedResp := ParseRespForUsage(ctx, responseText) - reqCtx.Usage = parsedResp.Usage + resp := parseRespForUsage(ctx, responseText) + reqCtx.Usage = resp.Usage + metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.PromptTokens) + metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.CompletionTokens) } - - if body.ResponseBody.EndOfStream { - loggerVerbose.Info("Streaming is completed") - reqCtx.ResponseComplete = true - } else { - reqCtx.ResponseSize += len(body.ResponseBody.Body) - } - - return nil } // Example message if "stream_options": {"include_usage": "true"} is included in the request: @@ -227,11 +109,12 @@ func (s *Server) HandleStreaming( // // If include_usage is not included in the request, `data: [DONE]` is returned separately, which // indicates end of streaming. -func ParseRespForUsage( +func parseRespForUsage( ctx context.Context, responseText string, ) Response { response := Response{} + logger := log.FromContext(ctx) lines := strings.Split(responseText, "\n") for _, line := range lines { @@ -245,8 +128,7 @@ func ParseRespForUsage( byteSlice := []byte(content) if err := json.Unmarshal(byteSlice, &response); err != nil { - logger := log.FromContext(ctx) - logger.V(logutil.DEFAULT).Error(err, "unmarshaling response body") + logger.Error(err, "unmarshaling response body") continue } } diff --git a/pkg/epp/handlers/response_test.go b/pkg/epp/handlers/response_test.go index 074b45c9..bfe5a629 100644 --- a/pkg/epp/handlers/response_test.go +++ b/pkg/epp/handlers/response_test.go @@ -18,9 +18,9 @@ package handlers import ( "context" + "encoding/json" "testing" - extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/google/go-cmp/cmp" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -63,40 +63,61 @@ func TestHandleResponseBody(t *testing.T) { tests := []struct { name string - req *extProcPb.ProcessingRequest_ResponseBody + body []byte reqCtx *RequestContext want Usage wantErr bool }{ { name: "success", - req: &extProcPb.ProcessingRequest_ResponseBody{ - ResponseBody: &extProcPb.HttpBody{ - Body: []byte(body), - }, - }, + body: []byte(body), want: Usage{ PromptTokens: 11, TotalTokens: 111, CompletionTokens: 100, }, }, - { - name: "malformed response", - req: &extProcPb.ProcessingRequest_ResponseBody{ - ResponseBody: &extProcPb.HttpBody{ - Body: []byte("malformed json"), - }, - }, - wantErr: true, - }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := &StreamingServer{} + reqCtx := test.reqCtx + if reqCtx == nil { + reqCtx = &RequestContext{} + } + var responseMap map[string]interface{} + marshalErr := json.Unmarshal(test.body, &responseMap) + if marshalErr != nil { + t.Error(marshalErr, "Error unmarshaling request body") + } + _, err := server.HandleResponseBody(ctx, reqCtx, responseMap) + if err != nil { + if !test.wantErr { + t.Fatalf("HandleResponseBody returned unexpected error: %v, want %v", err, test.wantErr) + } + return + } + + if diff := cmp.Diff(test.want, reqCtx.Usage); diff != "" { + t.Errorf("HandleResponseBody returned unexpected response, diff(-want, +got): %v", diff) + } + }) + } +} + +func TestHandleStreamedResponseBody(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + tests := []struct { + name string + body string + reqCtx *RequestContext + want Usage + wantErr bool + }{ { name: "streaming request without usage", - req: &extProcPb.ProcessingRequest_ResponseBody{ - ResponseBody: &extProcPb.HttpBody{ - Body: []byte(streamingBodyWithoutUsage), - }, - }, + body: streamingBodyWithoutUsage, reqCtx: &RequestContext{ modelServerStreaming: true, }, @@ -105,11 +126,7 @@ func TestHandleResponseBody(t *testing.T) { }, { name: "streaming request with usage", - req: &extProcPb.ProcessingRequest_ResponseBody{ - ResponseBody: &extProcPb.HttpBody{ - Body: []byte(streamingBodyWithUsage), - }, - }, + body: streamingBodyWithUsage, reqCtx: &RequestContext{ modelServerStreaming: true, }, @@ -124,18 +141,12 @@ func TestHandleResponseBody(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - server := &Server{} + server := &StreamingServer{} reqCtx := test.reqCtx if reqCtx == nil { reqCtx = &RequestContext{} } - _, err := server.HandleResponseBody(ctx, reqCtx, &extProcPb.ProcessingRequest{Request: test.req}) - if err != nil { - if !test.wantErr { - t.Fatalf("HandleResponseBody returned unexpected error: %v, want %v", err, test.wantErr) - } - return - } + server.HandleResponseBodyModelStreaming(ctx, reqCtx, test.body) if diff := cmp.Diff(test.want, reqCtx.Usage); diff != "" { t.Errorf("HandleResponseBody returned unexpected response, diff(-want, +got): %v", diff) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 862a73b4..7bb0fcb1 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -18,14 +18,23 @@ package handlers import ( "context" + "encoding/json" "io" + "math/rand" + "strconv" + "strings" "time" + configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "github.com/go-logr/logr" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" @@ -33,8 +42,8 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -func NewServer(scheduler Scheduler, destinationEndpointHintMetadataNamespace, destinationEndpointHintKey string, datastore datastore.Datastore) *Server { - return &Server{ +func NewStreamingServer(scheduler Scheduler, destinationEndpointHintMetadataNamespace, destinationEndpointHintKey string, datastore datastore.Datastore) *StreamingServer { + return &StreamingServer{ scheduler: scheduler, destinationEndpointHintMetadataNamespace: destinationEndpointHintMetadataNamespace, destinationEndpointHintKey: destinationEndpointHintKey, @@ -44,7 +53,7 @@ func NewServer(scheduler Scheduler, destinationEndpointHintMetadataNamespace, de // Server implements the Envoy external processing server. // https://www.envoyproxy.io/docs/envoy/latest/api-v3/service/ext_proc/v3/external_processor.proto -type Server struct { +type StreamingServer struct { scheduler Scheduler // The key of the header to specify the target pod address. This value needs to match Envoy // configuration. @@ -59,27 +68,75 @@ type Scheduler interface { Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (targetPod schedulingtypes.Pod, err error) } -func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { +// RequestContext stores context information during the life time of an HTTP request. +type RequestContext struct { + TargetPod string + TargetEndpoint string + Model string + ResolvedTargetModel string + RequestReceivedTimestamp time.Time + ResponseCompleteTimestamp time.Time + RequestSize int + Usage Usage + ResponseSize int + ResponseComplete bool + ResponseStatusCode string + RequestRunning bool + + RequestState StreamRequestState + modelServerStreaming bool + + reqHeaderResp *extProcPb.ProcessingResponse + reqBodyResp *extProcPb.ProcessingResponse + reqTrailerResp *extProcPb.ProcessingResponse + + respHeaderResp *extProcPb.ProcessingResponse + respBodyResp *extProcPb.ProcessingResponse + respTrailerResp *extProcPb.ProcessingResponse +} + +type StreamRequestState int + +const ( + RequestReceived StreamRequestState = 0 + HeaderRequestResponseComplete StreamRequestState = 1 + BodyRequestResponsesComplete StreamRequestState = 2 + TrailerRequestResponsesComplete StreamRequestState = 3 + ResponseRecieved StreamRequestState = 4 + HeaderResponseResponseComplete StreamRequestState = 5 + BodyResponseResponsesComplete StreamRequestState = 6 + TrailerResponseResponsesComplete StreamRequestState = 7 +) + +func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { ctx := srv.Context() logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Processing") + loggerTrace := logger.V(logutil.TRACE) + loggerTrace.Info("Processing") // Create request context to share states during life time of an HTTP request. // See https://github.com/envoyproxy/envoy/issues/17540. - reqCtx := &RequestContext{} + reqCtx := &RequestContext{ + RequestState: RequestReceived, + } - // Create variable for error handling as each request should only report once for - // error metric. This doesn't cover the error "Cannot receive stream request" because - // such error might happen even the response is processed. + var body []byte + var requestBody, responseBody map[string]interface{} + + // Create error handling var as each request should only report once for + // error metrics. This doesn't cover the error "Cannot receive stream request" because + // such errors might happen even though response is processed. var err error - defer func(error) { + defer func(error, *RequestContext) { if reqCtx.ResponseStatusCode != "" { metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseStatusCode) } else if err != nil { metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, errutil.CanonicalCode(err)) } - }(err) + if reqCtx.RequestRunning { + metrics.DecRunningRequests(reqCtx.Model) + } + }(err, reqCtx) for { select { @@ -95,70 +152,306 @@ func (s *Server) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { if recvErr != nil { // This error occurs very frequently, though it doesn't seem to have any impact. // TODO Figure out if we can remove this noise. - loggerVerbose.Error(err, "Cannot receive stream request") + logger.V(logutil.DEFAULT).Error(err, "Cannot receive stream request") return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) } - var resp *extProcPb.ProcessingResponse switch v := req.Request.(type) { case *extProcPb.ProcessingRequest_RequestHeaders: - reqCtx.RequestReceivedTimestamp = time.Now() - resp = HandleRequestHeaders(ctx, reqCtx, req) - loggerVerbose.Info("Request context after HandleRequestHeaders", "context", reqCtx) + err = s.HandleRequestHeaders(ctx, reqCtx, v) case *extProcPb.ProcessingRequest_RequestBody: - resp, err = s.HandleRequestBody(ctx, reqCtx, req) - if err == nil { - metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) - metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) + loggerTrace.Info("Incoming body chunk", "EoS", v.RequestBody.EndOfStream) + // In the stream case, we can receive multiple request bodies. + body = append(body, v.RequestBody.Body...) + + // Message is buffered, we can read and decode. + if v.RequestBody.EndOfStream { + loggerTrace.Info("decoding") + err = json.Unmarshal(body, &requestBody) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + } + + // Body stream complete. Allocate empty slice for response to use. + body = []byte{} + + reqCtx, err = s.HandleRequestBody(ctx, reqCtx, req, requestBody) + if err != nil { + logger.V(logutil.DEFAULT).Error(err, "Error handling body") + } else { + metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) + metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) + } } - loggerVerbose.Info("Request context after HandleRequestBody", "context", reqCtx) + case *extProcPb.ProcessingRequest_RequestTrailers: + // This is currently unused. case *extProcPb.ProcessingRequest_ResponseHeaders: - resp, err = s.HandleResponseHeaders(ctx, reqCtx, req) - loggerVerbose.Info("Request context after HandleResponseHeaders", "context", reqCtx) - case *extProcPb.ProcessingRequest_ResponseBody: - // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. - // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. - // using the standard 'err' var will send an immediate error response back to the caller. - var responseErr error - resp, responseErr = s.HandleResponseBody(ctx, reqCtx, req) - if responseErr != nil { - logger.V(logutil.DEFAULT).Error(responseErr, "Failed to process response body", "request", req) - } else if reqCtx.ResponseComplete { - reqCtx.ResponseCompleteTimestamp = time.Now() - metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) - metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) - metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) - metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) - metrics.RecordNormalizedTimePerOutputToken(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp, reqCtx.Usage.CompletionTokens) + for _, header := range v.ResponseHeaders.Headers.GetHeaders() { + value := string(header.RawValue) + + loggerTrace.Info("header", "key", header.Key, "value", value) + if header.Key == "status" && value != "200" { + reqCtx.ResponseStatusCode = errutil.ModelServerError + } else if header.Key == "content-type" && strings.Contains(value, "text/event-stream") { + reqCtx.modelServerStreaming = true + loggerTrace.Info("model server is streaming response") + } } + reqCtx.RequestState = ResponseRecieved + reqCtx.respHeaderResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + // This is for debugging purpose only. + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + }, + }, + }, + }, + }, + } + + case *extProcPb.ProcessingRequest_ResponseBody: if reqCtx.modelServerStreaming { - logger.V(logutil.DEBUG).Info("Request context after HandleResponseBody", "context", reqCtx) + // Currently we punt on response parsing if the modelServer is streaming, and we just passthrough. + + responseText := string(v.ResponseBody.Body) + s.HandleResponseBodyModelStreaming(ctx, reqCtx, responseText) + if v.ResponseBody.EndOfStream { + loggerTrace.Info("stream completed") + + reqCtx.ResponseCompleteTimestamp = time.Now() + metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) + metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) + } + + reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: v.ResponseBody.Body, + EndOfStream: v.ResponseBody.EndOfStream, + }, + }, + }, + }, + }, + }, + } } else { - loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) + body = append(body, v.ResponseBody.Body...) + + // Message is buffered, we can read and decode. + if v.ResponseBody.EndOfStream { + loggerTrace.Info("stream completed") + // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. + // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. + // using the standard 'err' var will send an immediate error response back to the caller. + var responseErr error + responseErr = json.Unmarshal(body, &responseBody) + if responseErr != nil { + logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body") + } + + reqCtx, responseErr = s.HandleResponseBody(ctx, reqCtx, responseBody) + if responseErr != nil { + logger.V(logutil.DEFAULT).Error(responseErr, "Failed to process response body", "request", req) + } else if reqCtx.ResponseComplete { + reqCtx.ResponseCompleteTimestamp = time.Now() + metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) + metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) + metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) + metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) + } + } } - default: - logger.V(logutil.DEFAULT).Error(nil, "Unknown Request type", "request", v) - return status.Error(codes.Unknown, "unknown request type") + case *extProcPb.ProcessingRequest_ResponseTrailers: + // This is currently unused. } + // Handle the err and fire an immediate response. if err != nil { logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) - resp, err = BuildErrResponse(err) + resp, err := BuildErrResponse(err) if err != nil { return err } + if err := srv.Send(resp); err != nil { + logger.V(logutil.DEFAULT).Error(err, "Send failed") + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + return nil + } + loggerTrace.Info("checking", "request state", reqCtx.RequestState) + if err := reqCtx.updateStateAndSendIfNeeded(srv, logger); err != nil { + return err + } + } +} + +// updateStateAndSendIfNeeded checks state and can send mutiple responses in a single pass, but only if ordered properly. +// Order of requests matter in FULL_DUPLEX_STREAMING. For both request and response, the order of response sent back MUST be: Header->Body->Trailer, with trailer being optional. +func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, logger logr.Logger) error { + loggerTrace := logger.V(logutil.TRACE) + // No switch statement as we could send multiple responses in one pass. + if r.RequestState == RequestReceived && r.reqHeaderResp != nil { + loggerTrace.Info("Sending request header response", "obj", r.reqHeaderResp) + if err := srv.Send(r.reqHeaderResp); err != nil { + logger.V(logutil.DEFAULT).Error(err, "error sending response") + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = HeaderRequestResponseComplete + } + if r.RequestState == HeaderRequestResponseComplete && r.reqBodyResp != nil { + loggerTrace.Info("Sending request body response") + if err := srv.Send(r.reqBodyResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = BodyRequestResponsesComplete + metrics.IncRunningRequests(r.Model) + r.RequestRunning = true + // Dump the response so a new stream message can begin + r.reqBodyResp = nil + } + if r.RequestState == BodyRequestResponsesComplete && r.reqTrailerResp != nil { + // Trailers in requests are not guaranteed + if err := srv.Send(r.reqHeaderResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + } + if r.RequestState == ResponseRecieved && r.respHeaderResp != nil { + loggerTrace.Info("Sending response header response", "obj", r.respHeaderResp) + if err := srv.Send(r.respHeaderResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } + r.RequestState = HeaderResponseResponseComplete + } + if r.RequestState == HeaderResponseResponseComplete && r.respBodyResp != nil { + loggerTrace.Info("Sending response body response") + if err := srv.Send(r.respBodyResp); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } - if !reqCtx.modelServerStreaming { - loggerVerbose.Info("Response generated", "response", resp) - } else { - logger.V(logutil.DEBUG).Info("Response generated", "response", resp) + body := r.respBodyResp.Response.(*extProcPb.ProcessingResponse_ResponseBody) + if body.ResponseBody.Response.GetBodyMutation().GetStreamedResponse().GetEndOfStream() { + r.RequestState = BodyResponseResponsesComplete } - if err := srv.Send(resp); err != nil { - logger.V(logutil.DEFAULT).Error(err, "Send failed") + // Dump the response so a new stream message can begin + r.respBodyResp = nil + } + if r.RequestState == BodyResponseResponsesComplete && r.respTrailerResp != nil { + // Trailers in requests are not guaranteed + if err := srv.Send(r.reqHeaderResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } + return nil +} + +func (s *StreamingServer) populateRequestHeaderResponse(reqCtx *RequestContext, endpoint string, requestBodyLength int) { + headers := []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: s.destinationEndpointHintKey, + RawValue: []byte(endpoint), + }, + }, + } + if requestBodyLength > 0 { + // We need to update the content length header if the body is mutated, see Envoy doc: + // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto + headers = append(headers, &configPb.HeaderValueOption{ + Header: &configPb.HeaderValue{ + Key: "Content-Length", + RawValue: []byte(strconv.Itoa(requestBodyLength)), + }, + }) + } + + targetEndpointValue := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintKey: { + Kind: &structpb.Value_StringValue{ + StringValue: endpoint, + }, + }, + }, + } + dynamicMetadata := targetEndpointValue + if s.destinationEndpointHintMetadataNamespace != "" { + // If a namespace is defined, wrap the selected endpoint with that. + dynamicMetadata = &structpb.Struct{ + Fields: map[string]*structpb.Value{ + s.destinationEndpointHintMetadataNamespace: { + Kind: &structpb.Value_StructValue{ + StructValue: targetEndpointValue, + }, + }, + }, + } + } + + reqCtx.reqHeaderResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestHeaders{ + RequestHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + ClearRouteCache: true, + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: headers, + }, + }, + }, + }, + DynamicMetadata: dynamicMetadata, + } +} + +func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed int64) string { + // TODO: after we are down to 1 server implementation, make these methods a part of the struct + // and handle random seeding on the struct. + source := rand.NewSource(rand.Int63()) + if seed > 0 { + source = rand.NewSource(seed) + } + r := rand.New(source) + + // all the weight values are nil, then we should return random model name + if model.Spec.TargetModels[0].Weight == nil { + index := r.Int31n(int32(len(model.Spec.TargetModels))) + return model.Spec.TargetModels[index].Name + } + + var weights int32 + for _, model := range model.Spec.TargetModels { + weights += *model.Weight + } + logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) + randomVal := r.Int31n(weights) + // TODO: optimize this without using loop + for _, model := range model.Spec.TargetModels { + if randomVal < *model.Weight { + return model.Name + } + randomVal -= *model.Weight + } + return "" +} + +func GetRandomPod(ds datastore.Datastore) *backendmetrics.Pod { + pods := ds.PodGetAll() + number := rand.Intn(len(pods)) + pod := pods[number] + return pod.GetPod() } func BuildErrResponse(err error) (*extProcPb.ProcessingResponse, error) { @@ -214,43 +507,3 @@ func BuildErrResponse(err error) (*extProcPb.ProcessingResponse, error) { } return resp, nil } - -// RequestContext stores context information during the life time of an HTTP request. -type RequestContext struct { - TargetPod string - TargetEndpoint string - Model string - ResolvedTargetModel string - RequestReceivedTimestamp time.Time - ResponseCompleteTimestamp time.Time - RequestSize int - Usage Usage - ResponseSize int - ResponseComplete bool - ResponseStatusCode string - RequestRunning bool - - RequestState StreamRequestState - modelServerStreaming bool - - reqHeaderResp *extProcPb.ProcessingResponse - reqBodyResp *extProcPb.ProcessingResponse - reqTrailerResp *extProcPb.ProcessingResponse - - respHeaderResp *extProcPb.ProcessingResponse - respBodyResp *extProcPb.ProcessingResponse - respTrailerResp *extProcPb.ProcessingResponse -} - -type StreamRequestState int - -const ( - RequestReceived StreamRequestState = 0 - HeaderRequestResponseComplete StreamRequestState = 1 - BodyRequestResponsesComplete StreamRequestState = 2 - TrailerRequestResponsesComplete StreamRequestState = 3 - ResponseRecieved StreamRequestState = 4 - HeaderResponseResponseComplete StreamRequestState = 5 - BodyResponseResponsesComplete StreamRequestState = 6 - TrailerResponseResponsesComplete StreamRequestState = 7 -) diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go deleted file mode 100644 index ca3451cb..00000000 --- a/pkg/epp/handlers/streamingserver.go +++ /dev/null @@ -1,594 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 handlers - -import ( - "context" - "encoding/json" - "fmt" - "io" - "math/rand" - "strconv" - "strings" - "time" - - configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" - extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" - "github.com/go-logr/logr" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/structpb" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -func NewStreamingServer(scheduler Scheduler, destinationEndpointHintMetadataNamespace, destinationEndpointHintKey string, datastore datastore.Datastore) *StreamingServer { - return &StreamingServer{ - scheduler: scheduler, - destinationEndpointHintMetadataNamespace: destinationEndpointHintMetadataNamespace, - destinationEndpointHintKey: destinationEndpointHintKey, - datastore: datastore, - } -} - -type StreamingServer struct { - scheduler Scheduler - // The key of the header to specify the target pod address. This value needs to match Envoy - // configuration. - destinationEndpointHintKey string - // The key acting as the outer namespace struct in the metadata extproc response to communicate - // back the picked endpoints. - destinationEndpointHintMetadataNamespace string - datastore datastore.Datastore -} - -func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { - ctx := srv.Context() - logger := log.FromContext(ctx) - loggerTrace := logger.V(logutil.TRACE) - loggerTrace.Info("Processing") - - // Create request context to share states during life time of an HTTP request. - // See https://github.com/envoyproxy/envoy/issues/17540. - reqCtx := &RequestContext{ - RequestState: RequestReceived, - } - - var body []byte - var requestBody, responseBody map[string]interface{} - - // Create error handling var as each request should only report once for - // error metrics. This doesn't cover the error "Cannot receive stream request" because - // such errors might happen even though response is processed. - var err error - defer func(error, *RequestContext) { - if reqCtx.ResponseStatusCode != "" { - metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseStatusCode) - } else if err != nil { - metrics.RecordRequestErrCounter(reqCtx.Model, reqCtx.ResolvedTargetModel, errutil.CanonicalCode(err)) - } - if reqCtx.RequestRunning { - metrics.DecRunningRequests(reqCtx.Model) - } - }(err, reqCtx) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - req, recvErr := srv.Recv() - if recvErr == io.EOF || status.Code(recvErr) == codes.Canceled { - return nil - } - if recvErr != nil { - // This error occurs very frequently, though it doesn't seem to have any impact. - // TODO Figure out if we can remove this noise. - logger.V(logutil.DEFAULT).Error(err, "Cannot receive stream request") - return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) - } - - switch v := req.Request.(type) { - case *extProcPb.ProcessingRequest_RequestHeaders: - err = s.HandleRequestHeaders(ctx, reqCtx, v) - case *extProcPb.ProcessingRequest_RequestBody: - loggerTrace.Info("Incoming body chunk", "EoS", v.RequestBody.EndOfStream) - // In the stream case, we can receive multiple request bodies. - body = append(body, v.RequestBody.Body...) - - // Message is buffered, we can read and decode. - if v.RequestBody.EndOfStream { - loggerTrace.Info("decoding") - err = json.Unmarshal(body, &requestBody) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") - } - - // Body stream complete. Allocate empty slice for response to use. - body = []byte{} - - reqCtx, err = s.HandleRequestBody(ctx, reqCtx, req, requestBody) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error handling body") - } else { - metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) - metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) - } - } - case *extProcPb.ProcessingRequest_RequestTrailers: - // This is currently unused. - case *extProcPb.ProcessingRequest_ResponseHeaders: - for _, header := range v.ResponseHeaders.Headers.GetHeaders() { - value := string(header.RawValue) - - loggerTrace.Info("header", "key", header.Key, "value", value) - if header.Key == "status" && value != "200" { - reqCtx.ResponseStatusCode = errutil.ModelServerError - } else if header.Key == "content-type" && strings.Contains(value, "text/event-stream") { - reqCtx.modelServerStreaming = true - loggerTrace.Info("model server is streaming response") - } - } - reqCtx.RequestState = ResponseRecieved - reqCtx.respHeaderResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseHeaders{ - ResponseHeaders: &extProcPb.HeadersResponse{ - Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - // This is for debugging purpose only. - Key: "x-went-into-resp-headers", - RawValue: []byte("true"), - }, - }, - }, - }, - }, - }, - }, - } - - case *extProcPb.ProcessingRequest_ResponseBody: - if reqCtx.modelServerStreaming { - // Currently we punt on response parsing if the modelServer is streaming, and we just passthrough. - - responseText := string(v.ResponseBody.Body) - s.HandleResponseBodyModelStreaming(ctx, reqCtx, responseText) - if v.ResponseBody.EndOfStream { - loggerTrace.Info("stream completed") - - reqCtx.ResponseCompleteTimestamp = time.Now() - metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) - metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) - metrics.RecordNormalizedTimePerOutputToken(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp, reqCtx.Usage.CompletionTokens) - } - - reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseBody{ - ResponseBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: v.ResponseBody.Body, - EndOfStream: v.ResponseBody.EndOfStream, - }, - }, - }, - }, - }, - }, - } - } else { - body = append(body, v.ResponseBody.Body...) - - // Message is buffered, we can read and decode. - if v.ResponseBody.EndOfStream { - loggerTrace.Info("stream completed") - // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. - // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. - // using the standard 'err' var will send an immediate error response back to the caller. - var responseErr error - responseErr = json.Unmarshal(body, &responseBody) - if responseErr != nil { - logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body") - } - - reqCtx, responseErr = s.HandleResponseBody(ctx, reqCtx, responseBody) - if responseErr != nil { - logger.V(logutil.DEFAULT).Error(responseErr, "Failed to process response body", "request", req) - } else if reqCtx.ResponseComplete { - reqCtx.ResponseCompleteTimestamp = time.Now() - metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) - metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) - metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) - metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) - metrics.RecordNormalizedTimePerOutputToken(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp, reqCtx.Usage.CompletionTokens) - } - } - } - case *extProcPb.ProcessingRequest_ResponseTrailers: - // This is currently unused. - } - - // Handle the err and fire an immediate response. - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Failed to process request", "request", req) - resp, err := BuildErrResponse(err) - if err != nil { - return err - } - if err := srv.Send(resp); err != nil { - logger.V(logutil.DEFAULT).Error(err, "Send failed") - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - return nil - } - loggerTrace.Info("checking", "request state", reqCtx.RequestState) - if err := reqCtx.updateStateAndSendIfNeeded(srv, logger); err != nil { - return err - } - } -} - -// updateStateAndSendIfNeeded checks state and can send mutiple responses in a single pass, but only if ordered properly. -// Order of requests matter in FULL_DUPLEX_STREAMING. For both request and response, the order of response sent back MUST be: Header->Body->Trailer, with trailer being optional. -func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, logger logr.Logger) error { - loggerTrace := logger.V(logutil.TRACE) - // No switch statement as we could send multiple responses in one pass. - if r.RequestState == RequestReceived && r.reqHeaderResp != nil { - loggerTrace.Info("Sending request header response", "obj", r.reqHeaderResp) - if err := srv.Send(r.reqHeaderResp); err != nil { - logger.V(logutil.DEFAULT).Error(err, "error sending response") - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - r.RequestState = HeaderRequestResponseComplete - } - if r.RequestState == HeaderRequestResponseComplete && r.reqBodyResp != nil { - loggerTrace.Info("Sending request body response") - if err := srv.Send(r.reqBodyResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - r.RequestState = BodyRequestResponsesComplete - metrics.IncRunningRequests(r.Model) - r.RequestRunning = true - // Dump the response so a new stream message can begin - r.reqBodyResp = nil - } - if r.RequestState == BodyRequestResponsesComplete && r.reqTrailerResp != nil { - // Trailers in requests are not guaranteed - if err := srv.Send(r.reqHeaderResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - } - if r.RequestState == ResponseRecieved && r.respHeaderResp != nil { - loggerTrace.Info("Sending response header response", "obj", r.respHeaderResp) - if err := srv.Send(r.respHeaderResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - r.RequestState = HeaderResponseResponseComplete - } - if r.RequestState == HeaderResponseResponseComplete && r.respBodyResp != nil { - loggerTrace.Info("Sending response body response") - if err := srv.Send(r.respBodyResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - - body := r.respBodyResp.Response.(*extProcPb.ProcessingResponse_ResponseBody) - if body.ResponseBody.Response.GetBodyMutation().GetStreamedResponse().GetEndOfStream() { - r.RequestState = BodyResponseResponsesComplete - } - // Dump the response so a new stream message can begin - r.respBodyResp = nil - } - if r.RequestState == BodyResponseResponsesComplete && r.respTrailerResp != nil { - // Trailers in requests are not guaranteed - if err := srv.Send(r.reqHeaderResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } - } - return nil -} - -// HandleRequestBody always returns the requestContext even in the error case, as the request context is used in error handling. -func (s *StreamingServer) HandleRequestBody( - ctx context.Context, - reqCtx *RequestContext, - req *extProcPb.ProcessingRequest, - requestBodyMap map[string]interface{}, -) (*RequestContext, error) { - var requestBodyBytes []byte - logger := log.FromContext(ctx) - - // Resolve target models. - model, ok := requestBodyMap["model"].(string) - if !ok { - return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} - } - - modelName := model - - // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. - // This might be a security risk in the future where adapters not registered in the InferenceModel - // are able to be requested by using their distinct name. - modelObj := s.datastore.ModelGet(model) - if modelObj == nil { - return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error finding a model object in InferenceModel for input %v", model)} - } - if len(modelObj.Spec.TargetModels) > 0 { - modelName = RandomWeightedDraw(logger, modelObj, 0) - if modelName == "" { - return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} - } - } - llmReq := &schedulingtypes.LLMRequest{ - Model: model, - ResolvedTargetModel: modelName, - Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, - } - logger.V(logutil.DEBUG).Info("LLM request assembled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "critical", llmReq.Critical) - - var err error - // Update target models in the body. - if llmReq.Model != llmReq.ResolvedTargetModel { - requestBodyMap["model"] = llmReq.ResolvedTargetModel - } - - requestBodyBytes, err = json.Marshal(requestBodyMap) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") - return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} - } - - target, err := s.scheduler.Schedule(ctx, llmReq) - if err != nil { - return reqCtx, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} - } - targetPod := target.GetPod() - - // Insert target endpoint to instruct Envoy to route requests to the specified target pod. - // Attach the port number - pool, err := s.datastore.PoolGet() - if err != nil { - return reqCtx, err - } - endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) - - logger.V(logutil.DEFAULT).Info("Request handled", - "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod, "endpoint metrics", - fmt.Sprintf("%+v", target)) - - reqCtx.Model = llmReq.Model - reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel - reqCtx.RequestSize = len(requestBodyBytes) - reqCtx.TargetPod = targetPod.NamespacedName.String() - reqCtx.TargetEndpoint = endpoint - - s.populateRequestHeaderResponse(reqCtx, endpoint, len(requestBodyBytes)) - - reqCtx.reqBodyResp = &extProcPb.ProcessingResponse{ - // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header - // and as an unstructure ext-proc response metadata key/value pair. This enables different integration - // options for gateway providers. - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: requestBodyBytes, - EndOfStream: true, - }, - }, - }, - }, - }, - }, - } - return reqCtx, nil -} - -// HandleResponseBody always returns the requestContext even in the error case, as the request context is used in error handling. -func (s *StreamingServer) HandleResponseBody( - ctx context.Context, - reqCtx *RequestContext, - response map[string]interface{}, -) (*RequestContext, error) { - logger := log.FromContext(ctx) - responseBytes, err := json.Marshal(response) - if err != nil { - logger.V(logutil.DEFAULT).Error(err, "error marshalling responseBody") - return reqCtx, err - } - if response["usage"] != nil { - usg := response["usage"].(map[string]interface{}) - usage := Usage{ - PromptTokens: int(usg["prompt_tokens"].(float64)), - CompletionTokens: int(usg["completion_tokens"].(float64)), - TotalTokens: int(usg["total_tokens"].(float64)), - } - reqCtx.Usage = usage - logger.V(logutil.VERBOSE).Info("Response generated", "usage", reqCtx.Usage) - } - reqCtx.ResponseSize = len(responseBytes) - // ResponseComplete is to indicate the response is complete. In non-streaming - // case, it will be set to be true once the response is processed; in - // streaming case, it will be set to be true once the last chunk is processed. - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/178) - // will add the processing for streaming case. - reqCtx.ResponseComplete = true - - reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ - // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header - // and as an unstructure ext-proc response metadata key/value pair. This enables different integration - // options for gateway providers. - Response: &extProcPb.ProcessingResponse_ResponseBody{ - ResponseBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: responseBytes, - EndOfStream: true, - }, - }, - }, - }, - }, - }, - } - return reqCtx, nil -} - -// The function is to handle streaming response if the modelServer is streaming. -func (s *StreamingServer) HandleResponseBodyModelStreaming( - ctx context.Context, - reqCtx *RequestContext, - responseText string, -) { - if strings.Contains(responseText, streamingEndMsg) { - resp := ParseRespForUsage(ctx, responseText) - metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.PromptTokens) - metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.CompletionTokens) - } -} - -func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *RequestContext, req *extProcPb.ProcessingRequest_RequestHeaders) error { - reqCtx.RequestReceivedTimestamp = time.Now() - - // an EoS in the request headers means this request has no body or trailers. - if req.RequestHeaders.EndOfStream { - // We will route this request to a random pod as this is assumed to just be a GET - // More context: https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/526 - // The above PR will address endpoint admission, but currently any request without a body will be - // routed to a random upstream pod. - pod := GetRandomPod(s.datastore) - pool, err := s.datastore.PoolGet() - if err != nil { - return err - } - endpoint := pod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) - s.populateRequestHeaderResponse(reqCtx, endpoint, 0) - } - return nil -} - -func (s *StreamingServer) populateRequestHeaderResponse(reqCtx *RequestContext, endpoint string, requestBodyLength int) { - headers := []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: s.destinationEndpointHintKey, - RawValue: []byte(endpoint), - }, - }, - } - if requestBodyLength > 0 { - // We need to update the content length header if the body is mutated, see Envoy doc: - // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/ext_proc/v3/processing_mode.proto - headers = append(headers, &configPb.HeaderValueOption{ - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte(strconv.Itoa(requestBodyLength)), - }, - }) - } - - targetEndpointValue := &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.destinationEndpointHintKey: { - Kind: &structpb.Value_StringValue{ - StringValue: endpoint, - }, - }, - }, - } - dynamicMetadata := targetEndpointValue - if s.destinationEndpointHintMetadataNamespace != "" { - // If a namespace is defined, wrap the selected endpoint with that. - dynamicMetadata = &structpb.Struct{ - Fields: map[string]*structpb.Value{ - s.destinationEndpointHintMetadataNamespace: { - Kind: &structpb.Value_StructValue{ - StructValue: targetEndpointValue, - }, - }, - }, - } - } - - reqCtx.reqHeaderResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestHeaders{ - RequestHeaders: &extProcPb.HeadersResponse{ - Response: &extProcPb.CommonResponse{ - ClearRouteCache: true, - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: headers, - }, - }, - }, - }, - DynamicMetadata: dynamicMetadata, - } -} - -func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed int64) string { - // TODO: after we are down to 1 server implementation, make these methods a part of the struct - // and handle random seeding on the struct. - source := rand.NewSource(rand.Int63()) - if seed > 0 { - source = rand.NewSource(seed) - } - r := rand.New(source) - - // all the weight values are nil, then we should return random model name - if model.Spec.TargetModels[0].Weight == nil { - index := r.Int31n(int32(len(model.Spec.TargetModels))) - return model.Spec.TargetModels[index].Name - } - - var weights int32 - for _, model := range model.Spec.TargetModels { - weights += *model.Weight - } - logger.V(logutil.TRACE).Info("Weights for model computed", "model", model.Name, "weights", weights) - randomVal := r.Int31n(weights) - // TODO: optimize this without using loop - for _, model := range model.Spec.TargetModels { - if randomVal < *model.Weight { - return model.Name - } - randomVal -= *model.Weight - } - return "" -} - -func GetRandomPod(ds datastore.Datastore) *backendmetrics.Pod { - pods := ds.PodGetAll() - number := rand.Intn(len(pods)) - pod := pods[number] - return pod.GetPod() -} diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 7ed183be..aa048e6e 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -146,14 +146,7 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { } else { srv = grpc.NewServer() } - var extProcServer extProcPb.ExternalProcessorServer - if r.UseStreaming { - logger.Info("Using streaming extproc server") - extProcServer = handlers.NewStreamingServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) - } else { - logger.Info("Using standard extproc server") - extProcServer = handlers.NewServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) - } + extProcServer := handlers.NewStreamingServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) extProcPb.RegisterExternalProcessorServer( srv, extProcServer, diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index ae2c6170..372158f4 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -92,325 +92,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestKubeInferenceModelRequest(t *testing.T) { - tests := []struct { - name string - req *extProcPb.ProcessingRequest - pods map[backendmetrics.Pod]*backendmetrics.Metrics - wantHeaders []*configPb.HeaderValueOption - wantMetadata *structpb.Struct - wantBody []byte - wantMetrics string - wantErr bool - immediateResponse *extProcPb.ImmediateResponse - }{ - { - name: "select lower queue and kv cache, no active lora", - req: integrationutils.GenerateRequest(logger, "test1", "my-model"), - // pod-1 will be picked because it has relatively low queue size and low KV cache. - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ - fakePod(0): { - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.2, - }, - fakePod(1): { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.1, - }, - fakePod(2): { - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - }, - }, - wantHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("192.168.1.2:8000"), - }, - }, - { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte("76"), - }, - }, - }, - wantMetadata: makeMetadata("192.168.1.2:8000"), - wantBody: []byte("{\"max_tokens\":100,\"model\":\"my-model-12345\",\"prompt\":\"test1\",\"temperature\":0}"), - wantMetrics: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="my-model",target_model_name="my-model-12345"} 1 - `, - wantErr: false, - }, - { - name: "select active lora, low queue", - req: integrationutils.GenerateRequest(logger, "test2", "sql-lora"), - // pod-1 will be picked because it has relatively low queue size, with the requested - // model being active, and has low KV cache. - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ - fakePod(0): { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(1): { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.1, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg2": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(2): { - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - WaitingModels: map[string]int{}, - }, - }, - wantHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("192.168.1.2:8000"), - }, - }, - { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte("76"), - }, - }, - }, - wantMetadata: makeMetadata("192.168.1.2:8000"), - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test2\",\"temperature\":0}"), - wantMetrics: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `, - wantErr: false, - }, - { - name: "select no lora despite active model, avoid excessive queue size", - req: integrationutils.GenerateRequest(logger, "test3", "sql-lora"), - // pod-2 will be picked despite it NOT having the requested model being active - // as it's above the affinity for queue size. Also is critical, so we should - // still honor request despite all queues > 5 - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ - fakePod(0): { - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(1): { - WaitingQueueSize: 200, - KVCacheUsagePercent: 0.1, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg2": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(2): { - WaitingQueueSize: 6, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - }, - WaitingModels: map[string]int{}, - }, - }, - wantHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("192.168.1.3:8000"), - }, - }, - { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte("76"), - }, - }, - }, - wantMetadata: makeMetadata("192.168.1.3:8000"), - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg2\",\"prompt\":\"test3\",\"temperature\":0}"), - wantMetrics: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora",target_model_name="sql-lora-1fdg2"} 1 - `, - wantErr: false, - }, - { - name: "noncritical and all models past threshold, shed request", - req: integrationutils.GenerateRequest(logger, "test4", "sql-lora-sheddable"), - // no pods will be picked as all models are either above kv threshold, - // queue threshold, or both. - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ - fakePod(0): { - WaitingQueueSize: 6, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - "sql-lora-1fdg3": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(1): { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.85, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(2): { - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.9, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, - WaitingModels: map[string]int{}, - }, - }, - wantHeaders: []*configPb.HeaderValueOption{}, - wantMetadata: &structpb.Struct{}, - wantBody: []byte(""), - wantErr: false, - immediateResponse: &extProcPb.ImmediateResponse{ - Status: &envoyTypePb.HttpStatus{ - Code: envoyTypePb.StatusCode_TooManyRequests, - }, - }, - wantMetrics: "", - }, - { - name: "noncritical, but one server has capacity, do not shed", - req: integrationutils.GenerateRequest(logger, "test5", "sql-lora-sheddable"), - // pod 0 will be picked as all other models are above threshold - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ - fakePod(0): { - WaitingQueueSize: 4, - KVCacheUsagePercent: 0.2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, - "sql-lora-1fdg3": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(1): { - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.85, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, - WaitingModels: map[string]int{}, - }, - fakePod(2): { - WaitingQueueSize: 10, - KVCacheUsagePercent: 0.9, - ActiveModels: map[string]int{ - "foo": 1, - "sql-lora-1fdg3": 1, - }, - WaitingModels: map[string]int{}, - }, - }, - wantHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - Key: runserver.DefaultDestinationEndpointHintKey, - RawValue: []byte("192.168.1.1:8000"), - }, - }, - { - Header: &configPb.HeaderValue{ - Key: "Content-Length", - RawValue: []byte("76"), - }, - }, - }, - wantMetadata: makeMetadata("192.168.1.1:8000"), - wantBody: []byte("{\"max_tokens\":100,\"model\":\"sql-lora-1fdg3\",\"prompt\":\"test5\",\"temperature\":0}"), - wantMetrics: ` - # HELP inference_model_request_total [ALPHA] Counter of inference model requests broken out for each model and target model. - # TYPE inference_model_request_total counter - inference_model_request_total{model_name="sql-lora-sheddable",target_model_name="sql-lora-1fdg3"} 1 - `, - wantErr: false, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, test.pods, false) - t.Cleanup(cleanup) - want := &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: test.wantHeaders, - }, - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_Body{ - Body: test.wantBody, - }, - }, - }, - }, - }, - DynamicMetadata: test.wantMetadata, - } - res, err := integrationutils.SendRequest(t, client, test.req) - - if err != nil && !test.wantErr { - t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) - } - if test.immediateResponse != nil { - want = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ImmediateResponse{ - ImmediateResponse: test.immediateResponse, - }, - } - } - if diff := cmp.Diff(want, res, protocmp.Transform()); diff != "" { - t.Errorf("Unexpected response, (-want +got): %v", diff) - } - - if test.wantMetrics != "" { - if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(test.wantMetrics), "inference_model_request_total"); err != nil { - t.Error(err) - } - } - - legacyregistry.Reset() - }) - } -} - func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { tests := []struct { name string From 92431f582ca4f8c6d75781e303acd8c84492dbea Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 14 Apr 2025 06:18:43 -0700 Subject: [PATCH 219/260] Document model server compatibility and config options (#537) * Document model server compatibility and config options * Update config/charts/inferencepool/README.md --------- Co-authored-by: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> --- config/charts/inferencepool/README.md | 14 ++++++- .../templates/epp-deployment.yaml | 9 ++++- config/charts/inferencepool/values.yaml | 1 + mkdocs.yml | 4 +- .../gateways.md} | 2 +- site-src/implementations/model-servers.md | 38 +++++++++++++++++++ 6 files changed, 64 insertions(+), 4 deletions(-) rename site-src/{implementations.md => implementations/gateways.md} (99%) create mode 100644 site-src/implementations/model-servers.md diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md index e5468cd4..301e3d9c 100644 --- a/config/charts/inferencepool/README.md +++ b/config/charts/inferencepool/README.md @@ -2,7 +2,6 @@ A chart to deploy an InferencePool and a corresponding EndpointPicker (epp) deployment. - ## Install To install an InferencePool named `vllm-llama3-8b-instruct` that selects from endpoints with label `app: vllm-llama3-8b-instruct` and listening on port `8000`, you can run the following command: @@ -23,6 +22,18 @@ $ helm install vllm-llama3-8b-instruct \ Note that the provider name is needed to deploy provider-specific resources. If no provider is specified, then only the InferencePool object and the EPP are deployed. +### Install for Triton TensorRT-LLM + +Use `--set inferencePool.modelServerType=triton-tensorrt-llm` to install for Triton TensorRT-LLM, e.g., + +```txt +$ helm install triton-llama3-8b-instruct \ + --set inferencePool.modelServers.matchLabels.app=triton-llama3-8b-instruct \ + --set inferencePool.modelServerType=triton-tensorrt-llm \ + --set provider.name=[none|gke] \ + oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0 +``` + ## Uninstall Run the following command to uninstall the chart: @@ -38,6 +49,7 @@ The following table list the configurable parameters of the chart. | **Parameter Name** | **Description** | |---------------------------------------------|------------------------------------------------------------------------------------------------------------------------| | `inferencePool.targetPortNumber` | Target port number for the vllm backends, will be used to scrape metrics by the inference extension. Defaults to 8000. | +| `inferencePool.modelServerType` | Type of the model servers in the pool, valid options are [vllm, triton-tensorrt-llm], default is vllm. | | `inferencePool.modelServers.matchLabels` | Label selector to match vllm backends managed by the inference pool. | | `inferenceExtension.replicas` | Number of replicas for the endpoint picker extension service. Defaults to `1`. | | `inferenceExtension.image.name` | Name of the container image used for the endpoint picker. | diff --git a/config/charts/inferencepool/templates/epp-deployment.yaml b/config/charts/inferencepool/templates/epp-deployment.yaml index 0b9fa0bd..fc490210 100644 --- a/config/charts/inferencepool/templates/epp-deployment.yaml +++ b/config/charts/inferencepool/templates/epp-deployment.yaml @@ -35,6 +35,14 @@ spec: - "9003" - -metricsPort - "9090" + {{- if eq (.Values.inferencePool.modelServerType | default "vllm") "triton-tensorrt-llm" }} + - -totalQueuedRequestsMetric + - "nv_trt_llm_request_metrics{request_type=waiting}" + - -kvCacheUsagePercentageMetric + - "nv_trt_llm_kv_cache_block_metrics{kv_cache_block_type=fraction}" + - -loraInfoMetric + - "" # Set an empty metric to disable LoRA metric scraping as they are not supported by Triton yet. + {{- end }} ports: - name: grpc containerPort: 9002 @@ -54,4 +62,3 @@ spec: service: inference-extension initialDelaySeconds: 5 periodSeconds: 10 - diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml index 766ee087..bd48f37e 100644 --- a/config/charts/inferencepool/values.yaml +++ b/config/charts/inferencepool/values.yaml @@ -9,6 +9,7 @@ inferenceExtension: inferencePool: targetPortNumber: 8000 + modelServerType: vllm # vllm, triton-tensorrt-llm # modelServers: # REQUIRED # matchLabels: # app: vllm-llama3-8b-instruct diff --git a/mkdocs.yml b/mkdocs.yml index b67cf8b4..bdfffe05 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,7 +54,9 @@ nav: API Overview: concepts/api-overview.md Conformance: concepts/conformance.md Roles and Personas: concepts/roles-and-personas.md - - Implementations: implementations.md + - Implementations: + - Gateways: implementations/gateways.md + - Model Servers: implementations/model-servers.md - FAQ: faq.md - Guides: - User Guides: diff --git a/site-src/implementations.md b/site-src/implementations/gateways.md similarity index 99% rename from site-src/implementations.md rename to site-src/implementations/gateways.md index dc15b297..d4e919be 100644 --- a/site-src/implementations.md +++ b/site-src/implementations/gateways.md @@ -1,4 +1,4 @@ -# Implementations +# Gateway Implementations This project has several implementations that are planned or in progress: diff --git a/site-src/implementations/model-servers.md b/site-src/implementations/model-servers.md new file mode 100644 index 00000000..3d475aaa --- /dev/null +++ b/site-src/implementations/model-servers.md @@ -0,0 +1,38 @@ + + +# Supported Model Servers + +Any model server that conform to the [model server protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol) are supported by the inference extension. + +## Compatible Model Server Versions + +| Model Server | Version | Commit | Notes | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| vLLM V0 | v0.6.4 and above | [commit 0ad216f](https://github.com/vllm-project/vllm/commit/0ad216f5750742115c686723bf38698372d483fd) | | +| vLLM V1 | v0.8.0 and above | [commit bc32bc7](https://github.com/vllm-project/vllm/commit/bc32bc73aad076849ac88565cff745b01b17d89c) | | +| Triton(TensorRT-LLM) | [25.03](https://docs.nvidia.com/deeplearning/triton-inference-server/release-notes/rel-25-03.html#rel-25-03) and above | [commit 15cb989](https://github.com/triton-inference-server/tensorrtllm_backend/commit/15cb989b00523d8e92dce5165b9b9846c047a70d). | LoRA affinity feature is not available as the required LoRA metrics haven't been implemented in Triton yet. | + +## vLLM + +vLLM is configured as the default in the [endpoint picker extension](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp). No further configuration is required. + +## Triton with TensorRT-LLM Backend + +Triton specific metric names need to be specified when starting the EPP. + +### Option 1: Use Helm + +Use `--set inferencePool.modelServerType=triton-tensorrt-llm` to install the [`inferencepool` via helm](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/42eb5ff1c5af1275df43ac384df0ddf20da95134/config/charts/inferencepool). See the [`inferencepool` helm guide](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/42eb5ff1c5af1275df43ac384df0ddf20da95134/config/charts/inferencepool/README.md) for more details. + +### Option 2: Edit EPP deployment yaml + + Add the following to the `args` of the [EPP deployment](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/42eb5ff1c5af1275df43ac384df0ddf20da95134/config/manifests/inferencepool-resources.yaml#L32) + + ``` +- -totalQueuedRequestsMetric +- "nv_trt_llm_request_metrics{request_type=waiting}" +- -kvCacheUsagePercentageMetric +- "nv_trt_llm_kv_cache_block_metrics{kv_cache_block_type=fraction}" +- -loraInfoMetric +- "" # Set an empty metric to disable LoRA metric scraping as they are not supported by Triton yet. +``` \ No newline at end of file From bd9ee36450d68fb4d0d8ac4f9be4db7d1ec4fee3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:45:07 -0700 Subject: [PATCH 220/260] Bump github.com/prometheus/client_model from 0.6.1 to 0.6.2 (#687) Bumps [github.com/prometheus/client_model](https://github.com/prometheus/client_model) from 0.6.1 to 0.6.2. - [Release notes](https://github.com/prometheus/client_model/releases) - [Commits](https://github.com/prometheus/client_model/compare/v0.6.1...v0.6.2) --- updated-dependencies: - dependency-name: github.com/prometheus/client_model dependency-version: 0.6.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 20cf017a..c3ad8e5d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/prometheus/client_golang v1.21.1 - github.com/prometheus/client_model v0.6.1 + github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.63.0 github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 diff --git a/go.sum b/go.sum index cd6cd380..838eb402 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,8 @@ github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4 github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= From b18abf248857b1f5599a9a29486a4f8a182a9906 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 20:47:06 -0700 Subject: [PATCH 221/260] Bump github.com/prometheus/client_golang from 1.21.1 to 1.22.0 (#688) Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.1 to 1.22.0. - [Release notes](https://github.com/prometheus/client_golang/releases) - [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md) - [Commits](https://github.com/prometheus/client_golang/compare/v1.21.1...v1.22.0) --- updated-dependencies: - dependency-name: github.com/prometheus/client_golang dependency-version: 1.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 3 +-- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index c3ad8e5d..4a0d5d63 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 - github.com/prometheus/client_golang v1.21.1 + github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.63.0 github.com/stretchr/testify v1.10.0 @@ -74,7 +74,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.11 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/go.sum b/go.sum index 838eb402..c551d3ed 100644 --- a/go.sum +++ b/go.sum @@ -112,8 +112,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -164,8 +164,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= From cd8e91f325221a2f0ea21a269c2a3092108e64c9 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 16 Apr 2025 07:05:05 +0300 Subject: [PATCH 222/260] added badges to README (#682) * added badges to README Signed-off-by: Nir Rozenbaum * typo Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b74a13e9..f7943d2f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +[![Go Report Card](https://goreportcard.com/badge/sigs.k8s.io/gateway-api-inference-extension)](https://goreportcard.com/report/sigs.k8s.io/gateway-api-inference-extension) +[![Go Reference](https://pkg.go.dev/badge/sigs.k8s.io/gateway-api-inference-extension.svg)](https://pkg.go.dev/sigs.k8s.io/gateway-api-inference-extension) +[![License](https://img.shields.io/github/license/kubernetes-sigs/gateway-api-inference-extension)](/LICENSE) + # Gateway API Inference Extension This extension upgrades an [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter)-capable proxy or gateway - such as Envoy Gateway, kGateway, or the GKE Gateway - to become an **inference gateway** - supporting inference platform teams self-hosting large language models on Kubernetes. This integration makes it easy to expose and control access to your local [OpenAI-compatible chat completion endpoints](https://platform.openai.com/docs/api-reference/chat) to other workloads on or off cluster, or to integrate your self-hosted models alongside model-as-a-service providers in a higher level **AI Gateway** like LiteLLM, Solo AI Gateway, or Apigee. From f7faddc277a335c49e129b8c0a1d7fe179718f95 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 21:05:12 -0700 Subject: [PATCH 223/260] Bump sigs.k8s.io/structured-merge-diff/v4 from 4.6.0 to 4.7.0 (#686) Bumps [sigs.k8s.io/structured-merge-diff/v4](https://github.com/kubernetes-sigs/structured-merge-diff) from 4.6.0 to 4.7.0. - [Release notes](https://github.com/kubernetes-sigs/structured-merge-diff/releases) - [Changelog](https://github.com/kubernetes-sigs/structured-merge-diff/blob/master/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/structured-merge-diff/compare/v4.6.0...v4.7.0) --- updated-dependencies: - dependency-name: sigs.k8s.io/structured-merge-diff/v4 dependency-version: 4.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4a0d5d63..fcfb60af 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( k8s.io/component-base v0.32.3 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.4 - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 sigs.k8s.io/yaml v1.4.0 ) diff --git a/go.sum b/go.sum index c551d3ed..b2c05a61 100644 --- a/go.sum +++ b/go.sum @@ -332,7 +332,7 @@ sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1 sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016 h1:kXv6kKdoEtedwuqMmkqhbkgvYKeycVbC8+iPCP9j5kQ= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= From 6a18bebff710ce1596b57e7399814f64ac033084 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Wed, 16 Apr 2025 12:19:38 -0700 Subject: [PATCH 224/260] Docs: Adds Kgateway Cleanup to Quickstart Signed-off-by: Daneyon Hansen --- site-src/guides/index.md | 46 ++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/site-src/guides/index.md b/site-src/guides/index.md index df3d1760..bcd1068d 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -119,9 +119,9 @@ This quickstart guide is intended for engineers familiar with k8s and model serv 5. Given that the default connection timeout may be insufficient for most inference workloads, it is recommended to configure a timeout appropriate for your intended use case. - ```bash - kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml - ``` + ```bash + kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gcp-backend-policy.yaml + ``` === "Istio" @@ -269,10 +269,10 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Cleanup - The following cleanup assumes you would like to clean ALL resources that were created in this quickstart guide. + The following instructions assume you would like to cleanup ALL resources that were created in this quickstart guide. Please be careful not to delete resources you'd like to keep. - 1. Uninstall the Inference Pool + 1. Uninstall the InferencePool, InferenceModel, and model server resources ```bash kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencepool-resources.yaml --ignore-not-found @@ -282,7 +282,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl delete secret hf-token --ignore-not-found ``` - 1. Uninstall the Gateway + 1. Uninstall the Gateway API resources ```bash kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/gke/gateway.yaml --ignore-not-found @@ -296,8 +296,40 @@ This quickstart guide is intended for engineers familiar with k8s and model serv kubectl delete -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/gateway/kgateway/httproute.yaml --ignore-not-found ``` - 1. Uninstall the CRDs + 1. Uninstall the Gateway API Inference Extension CRDs ```bash kubectl delete -k https://github.com/kubernetes-sigs/gateway-api-inference-extension/config/crd --ignore-not-found ``` + + 1. Choose one of the following options to cleanup the Inference Gateway. + +=== "GKE" + + **TODO** + +=== "Istio" + + **TODO** + +=== "Kgateway" + + The following instructions assume you would like to cleanup ALL Kgateway resources that were created in this quickstart guide. + + 1. Uninstall Kgateway + + ```bash + helm uninstall kgateway -n kgateway-system + ``` + + 1. Uninstall the Kgateway CRDs. + + ```bash + helm uninstall kgateway-crds -n kgateway-system + ``` + + 1. Remove the Kgateway namespace. + + ```bash + kubectl delete ns kgateway-system + ``` From 944d63cc204ea6fc54c2b2aca4cdbb7966da1fe4 Mon Sep 17 00:00:00 2001 From: Maxime Brunet Date: Thu, 17 Apr 2025 15:33:08 +0000 Subject: [PATCH 225/260] docs(gateways): fix Envoy AI Gateway link (#700) --- site-src/implementations/gateways.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site-src/implementations/gateways.md b/site-src/implementations/gateways.md index d4e919be..c3e17acd 100644 --- a/site-src/implementations/gateways.md +++ b/site-src/implementations/gateways.md @@ -13,15 +13,15 @@ This project has several implementations that are planned or in progress: ## Envoy AI Gateway [Envoy AI Gateway][aigw-home] is an open source project built on top of -[Envoy][envoy-org] and [Envoy Gateway][aigw-gateway] to handle request traffic +[Envoy][envoy-org] and [Envoy Gateway][envoy-gateway] to handle request traffic from application clients to GenAI services. The features and capabilities are outlined [here][aigw-capabilities]. Use the [quickstart][aigw-quickstart] to get Envoy AI Gateway running with Gateway API in a few simple steps. Progress towards supporting this project is tracked with a [GitHub Issue](https://github.com/envoyproxy/ai-gateway/issues/423). -[aigw-home]:https://gateway.envoyproxy.io/ +[aigw-home]:https://aigateway.envoyproxy.io/ [envoy-org]:https://github.com/envoyproxy -[aigw-gateway]: https://gateway.envoyproxy.io/ +[envoy-gateway]: https://gateway.envoyproxy.io/ [aigw-capabilities]:https://aigateway.envoyproxy.io/docs/capabilities/ [aigw-quickstart]:https://aigateway.envoyproxy.io/docs/capabilities/gateway-api-inference-extension From 4d7738a37be1bcc29afaa907949f632c48496e0c Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 17 Apr 2025 18:49:07 +0300 Subject: [PATCH 226/260] minor changes in few places (#702) * minor changes in few places Signed-off-by: Nir Rozenbaum * removed empty labels field Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- config/manifests/inferencepool-resources.yaml | 3 ++- pkg/epp/controller/inferencepool_reconciler.go | 6 ++---- pkg/epp/controller/inferencepool_reconciler_test.go | 2 +- pkg/epp/server/controller_manager.go | 6 +++--- pkg/epp/server/runserver.go | 6 +----- site-src/implementations/gateways.md | 2 ++ 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/config/manifests/inferencepool-resources.yaml b/config/manifests/inferencepool-resources.yaml index 993b7bf6..3d978292 100644 --- a/config/manifests/inferencepool-resources.yaml +++ b/config/manifests/inferencepool-resources.yaml @@ -4,7 +4,6 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: - labels: name: vllm-llama3-8b-instruct spec: targetPortNumber: 8000 @@ -54,6 +53,8 @@ spec: args: - -poolName - "vllm-llama3-8b-instruct" + - "-poolNamespace" + - "default" - -v - "4" - --zap-encoder diff --git a/pkg/epp/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go index c92d4ecc..0738181f 100644 --- a/pkg/epp/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -21,7 +21,6 @@ import ( "reflect" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -36,9 +35,8 @@ import ( // will have the proper controller that will create/manage objects on behalf of the server pool. type InferencePoolReconciler struct { client.Client - Record record.EventRecorder - PoolNamespacedName types.NamespacedName - Datastore datastore.Datastore + Record record.EventRecorder + Datastore datastore.Datastore } func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { diff --git a/pkg/epp/controller/inferencepool_reconciler_test.go b/pkg/epp/controller/inferencepool_reconciler_test.go index 7e5d4801..b7e28334 100644 --- a/pkg/epp/controller/inferencepool_reconciler_test.go +++ b/pkg/epp/controller/inferencepool_reconciler_test.go @@ -96,7 +96,7 @@ func TestInferencePoolReconciler(t *testing.T) { pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) datastore := datastore.NewDatastore(ctx, pmf) - inferencePoolReconciler := &InferencePoolReconciler{PoolNamespacedName: namespacedName, Client: fakeClient, Datastore: datastore} + inferencePoolReconciler := &InferencePoolReconciler{Client: fakeClient, Datastore: datastore} // Step 1: Inception, only ready pods matching pool1 are added to the store. if _, err := inferencePoolReconciler.Reconcile(ctx, req); err != nil { diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index aaad8976..ce5cfc89 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -39,8 +39,8 @@ func init() { utilruntime.Must(v1alpha2.Install(scheme)) } -// DefaultManagerOptions returns the default options used to create the manager. -func DefaultManagerOptions(namespace, name string) ctrl.Options { +// defaultManagerOptions returns the default options used to create the manager. +func defaultManagerOptions(namespace string, name string) ctrl.Options { return ctrl.Options{ Scheme: scheme, Cache: cache.Options{ @@ -71,7 +71,7 @@ func DefaultManagerOptions(namespace, name string) ctrl.Options { // NewDefaultManager creates a new controller manager with default configuration. func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Manager, error) { - manager, err := ctrl.NewManager(restConfig, DefaultManagerOptions(namespace, name)) + manager, err := ctrl.NewManager(restConfig, defaultManagerOptions(namespace, name)) if err != nil { return nil, fmt.Errorf("failed to create controller manager: %v", err) } diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index aa048e6e..65a6e787 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -87,11 +87,7 @@ func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Man if err := (&controller.InferencePoolReconciler{ Datastore: r.Datastore, Client: mgr.GetClient(), - PoolNamespacedName: types.NamespacedName{ - Name: r.PoolName, - Namespace: r.PoolNamespace, - }, - Record: mgr.GetEventRecorderFor("InferencePool"), + Record: mgr.GetEventRecorderFor("InferencePool"), }).SetupWithManager(mgr); err != nil { return fmt.Errorf("failed setting up InferencePoolReconciler: %w", err) } diff --git a/site-src/implementations/gateways.md b/site-src/implementations/gateways.md index c3e17acd..b44dca6f 100644 --- a/site-src/implementations/gateways.md +++ b/site-src/implementations/gateways.md @@ -5,10 +5,12 @@ This project has several implementations that are planned or in progress: * [Envoy AI Gateway][1] * [Kgateway][2] * [Google Kubernetes Engine][3] +* [Istio][4] [1]:#envoy-gateway [2]:#kgateway [3]:#google-kubernetes-engine +[4]:#istio ## Envoy AI Gateway From 8b9aef6b18d710ab6d17bc9c682e819de7156be4 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 17 Apr 2025 20:41:08 +0300 Subject: [PATCH 227/260] using namespaced name (#707) Signed-off-by: Nir Rozenbaum --- cmd/epp/main.go | 7 ++++++- pkg/epp/server/controller_manager.go | 15 ++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index b9c7d6e4..b5e6fbe6 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -30,6 +30,7 @@ import ( "go.uber.org/zap/zapcore" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "k8s.io/component-base/metrics/legacyregistry" ctrl "sigs.k8s.io/controller-runtime" @@ -140,7 +141,11 @@ func run() error { return err } - mgr, err := runserver.NewDefaultManager(*poolNamespace, *poolName, cfg) + poolNamespacedName := types.NamespacedName{ + Namespace: *poolNamespace, + Name: *poolName, + } + mgr, err := runserver.NewDefaultManager(poolNamespacedName, cfg) if err != nil { setupLog.Error(err, "Failed to create controller manager") return err diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index ce5cfc89..e5668210 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -22,6 +22,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -40,28 +41,28 @@ func init() { } // defaultManagerOptions returns the default options used to create the manager. -func defaultManagerOptions(namespace string, name string) ctrl.Options { +func defaultManagerOptions(namespacedName types.NamespacedName) ctrl.Options { return ctrl.Options{ Scheme: scheme, Cache: cache.Options{ ByObject: map[client.Object]cache.ByObject{ &corev1.Pod{}: { Namespaces: map[string]cache.Config{ - namespace: {}, + namespacedName.Namespace: {}, }, }, &v1alpha2.InferencePool{}: { Namespaces: map[string]cache.Config{ - namespace: { + namespacedName.Namespace: { FieldSelector: fields.SelectorFromSet(fields.Set{ - "metadata.name": name, + "metadata.name": namespacedName.Name, }), }, }, }, &v1alpha2.InferenceModel{}: { Namespaces: map[string]cache.Config{ - namespace: {}, + namespacedName.Namespace: {}, }, }, }, @@ -70,8 +71,8 @@ func defaultManagerOptions(namespace string, name string) ctrl.Options { } // NewDefaultManager creates a new controller manager with default configuration. -func NewDefaultManager(namespace, name string, restConfig *rest.Config) (ctrl.Manager, error) { - manager, err := ctrl.NewManager(restConfig, defaultManagerOptions(namespace, name)) +func NewDefaultManager(namespacedName types.NamespacedName, restConfig *rest.Config) (ctrl.Manager, error) { + manager, err := ctrl.NewManager(restConfig, defaultManagerOptions(namespacedName)) if err != nil { return nil, fmt.Errorf("failed to create controller manager: %v", err) } From c54650602b6a2599846787f8c139995dbbe62560 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 21 Apr 2025 12:01:01 -0700 Subject: [PATCH 228/260] EPP Architecture proposal (#683) * initial changes * Adding to proposal to give a quick barebones definition to refactor * feedback changes * more feedback addressing --- .../00x-epp-compliance-proposal/README.md | 99 +++++++++++++++++++ .../images/epp_arch.svg | 1 + 2 files changed, 100 insertions(+) create mode 100644 docs/proposals/00x-epp-compliance-proposal/README.md create mode 100644 docs/proposals/00x-epp-compliance-proposal/images/epp_arch.svg diff --git a/docs/proposals/00x-epp-compliance-proposal/README.md b/docs/proposals/00x-epp-compliance-proposal/README.md new file mode 100644 index 00000000..48c7720f --- /dev/null +++ b/docs/proposals/00x-epp-compliance-proposal/README.md @@ -0,0 +1,99 @@ +# Gateway API Inference Extension + +Author(s): @kfswain +## Proposal Status + ***Draft*** + +## Table of Contents + + + +- [Summary](#summary) +- [Goals](#goals) +- [Non-Goals](#non-goals) +- [Proposal](#proposal) + - [Personas](#personas) + - [Inference Platform Admin](#inference-platform-admin) + - [Inference Workload Owner](#workload-owner) + - [Axioms](#axioms) + - [InferencePool](#inferencepool) + - [InferenceModel](#inferencemodel) + - [Spec](#spec) + - [Diagrams](#diagrams) + - [Alternatives](#alternatives) +- [Open Questions](#open-questions) + + + +## Summary + +This proposal seeks to standardize the implementation of an EPP (End-point Picker) for the Inference Gateway extension (also known as Gateway API Inference Extension). Additionally, this proposes to restructure the current implementation of the EPP to be more modular, and approachable. + +## Goals + +- Set a standard on how the EPP & APIs interact +- Settle on common nomenclature for clearer communication +- Allow for modularization of the EPP, to be extended to a user's specific needs + +## Non-Goals + +- Reshaping the current API +- A change in scope of the current project + +## Proposal + +This proposal is not proposing any net new features, instead, we are refactoring our current implementation to better handle more devs, more features, etc. At the time of writing, GIE is currently at v0.3, and that stronger experimental context (along with external feedback) made clear the need this restructure. The image below give a high level view of how our components work together. + +Scheduling Algorithm + +## Overview +At a quick glance, the EPP is being broken into specific layers. The `Data Layer` is of note, as it is a vertical that will be accessed by all the others. The data layer manages the k8s, data, metric & usage data, as well as processing of the above data to determine resource scarcity regimes. + +The other layers are handled in sequential process. Starting with the **Ext-Proc** call. The request is buffered and then sent to the **Routing Layer**, which processes any User defined per-InferenceModel routing rules & request enrichment happening first (at the time of writing that is currently just translating the InferenceModel name to a weight-split actual model). Then _all_ requests pass through the to-be-implemented [**Flow Controller**](https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/674) to ensure that any request entry to the pool adhereing to the guidelines set by the Priority, Fairness, & Queueing configuration. And finally, the **Scheduling Layer** is the load balancing algorithm that intelligently routes requests based on the current state of the InferencePool. + +## Components + +To further expand upon these component layers. We will first break them into `extensible` and `non-extensible` layers. `Non-extensible` layers are intended to be static, and handled on behalf of the user, typically implementing low-opinion infrastructure. + +The `Extensible` layers are: +- Data Layer +- Routing Layer +- Flow Controller +- Scheduling Layer + +The `Non-Extensible` layer(s) are: +- The Ext-Proc Server + +### `Extensible` + +#### Data Layer + +The data layer will consume and store: the InferencePool/InferenceModel config and the pre-defined [Model Server Protocol](../003-model-server-protocol/README.md). Additionally, the data fed from the model servers will be processed and digested to provide resource scarcity regime hints, and autoscaling reccomendations. + +Many extensions to scheduling will require changes to ingested metrics, as such, the data layer will be built to be extended, but extenders accept that the Model Server Protocol will no longer provide guarantees on portability of a model server out of the box. + +#### Routing Layer + +The routing layer is likely to be the most opinion heavy section, as the scope of what constitutes a 'Route Rule' is somewhat broad. The current examples we expect would be: + +- System Prompt injection +- RAG callout +- Per-InferenceModel request validation (such as saftey/on-topic, etc) + +Due to the possibility of this becoming a bit of a dumping ground. The API will keep a _very_ tight scope on which of these route rules are included in the spec. A standard method of extension will be provided if the need to define a custom rule arises. + +#### Flow Controller (WIP - implementation tracked in [#674](https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/674)) + +The flow controller will consume resource regime data, and enforce proper resource sharing between workloads. This will primarily be done through a queuing mechanism [as described here](https://docs.google.com/document/d/1VZL7opFWuwgWquvgiOzLlXAJ633qZ9U-A0ZixGjBgaI/edit?usp=sharing). + +#### Scheduling Layer + +As the Scheduling Layer is the final interface to the entirety of the pool, all configuration will be at the _pool_ level. The default scheduling layer will be an experimentally-backed LB algorithm, with exposed config values. + +The Scheduler will define a strong interface API, so that new scheduling algos may be plugged & dark-launched to test in production traffic without impacting said traffic. Extension is expected to adhere to the [Scheduler Subsystem definition](https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/603) + +### `Non-extensible` + +#### Ext-Proc Server + +The Ext-Proc Server protocol is very well defined & specific, deviation could cause the EPP to become unusable or unstable. Extension is ill-advised. diff --git a/docs/proposals/00x-epp-compliance-proposal/images/epp_arch.svg b/docs/proposals/00x-epp-compliance-proposal/images/epp_arch.svg new file mode 100644 index 00000000..4c585728 --- /dev/null +++ b/docs/proposals/00x-epp-compliance-proposal/images/epp_arch.svg @@ -0,0 +1 @@ + \ No newline at end of file From c618e1f42ff73bbfaefb86ab74ea2971e21ca892 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 22 Apr 2025 18:49:41 +0300 Subject: [PATCH 229/260] removed unused Fake struct (#723) Signed-off-by: Nir Rozenbaum --- pkg/epp/backend/metrics/fake.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/pkg/epp/backend/metrics/fake.go b/pkg/epp/backend/metrics/fake.go index 7fd4970d..ec97c6de 100644 --- a/pkg/epp/backend/metrics/fake.go +++ b/pkg/epp/backend/metrics/fake.go @@ -24,7 +24,6 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -84,11 +83,3 @@ func (f *FakePodMetricsClient) SetErr(new map[types.NamespacedName]error) { defer f.errMu.Unlock() f.Err = new } - -type FakeDataStore struct { - Res map[string]*v1alpha2.InferenceModel -} - -func (fds *FakeDataStore) FetchModelData(modelName string) (returnModel *v1alpha2.InferenceModel) { - return fds.Res[modelName] -} From 9114b35d859c44fae9d9139f03d228e2b0748413 Mon Sep 17 00:00:00 2001 From: John Howard Date: Tue, 22 Apr 2025 14:59:40 -0700 Subject: [PATCH 230/260] epp: return correct response for trailers (#726) This looks like a copy paste error. --- pkg/epp/handlers/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 7bb0fcb1..f97e9ede 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -325,7 +325,7 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces } if r.RequestState == BodyRequestResponsesComplete && r.reqTrailerResp != nil { // Trailers in requests are not guaranteed - if err := srv.Send(r.reqHeaderResp); err != nil { + if err := srv.Send(r.reqTrailerResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } @@ -351,7 +351,7 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces } if r.RequestState == BodyResponseResponsesComplete && r.respTrailerResp != nil { // Trailers in requests are not guaranteed - if err := srv.Send(r.reqHeaderResp); err != nil { + if err := srv.Send(r.respTrailerResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } } From 45209f6bb93710c8a9fabc0c9f183dad0e2e94e0 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 22 Apr 2025 15:15:41 -0700 Subject: [PATCH 231/260] Refactor scheduler to run plugins (#677) * Refactor scheduler to run plugins * Add scheduler plugin latency metric * Address comments * Address comments --- pkg/epp/backend/metrics/types.go | 6 + pkg/epp/handlers/request.go | 9 +- pkg/epp/handlers/server.go | 2 +- pkg/epp/metrics/metrics.go | 22 ++ pkg/epp/metrics/metrics_test.go | 64 ++++ ...heduler_plugin_processing_latencies_metric | 67 ++++ pkg/epp/scheduling/config/config.go | 58 +++ pkg/epp/scheduling/{ => plugins}/filter.go | 144 ++++---- .../scheduling/{ => plugins}/filter_test.go | 91 ++--- pkg/epp/scheduling/plugins/noop.go | 38 ++ pkg/epp/scheduling/plugins/picker.go | 37 ++ pkg/epp/scheduling/scheduler.go | 236 ++++++++----- pkg/epp/scheduling/scheduler_test.go | 331 ++++++++++++++++-- pkg/epp/scheduling/types/interfaces.go | 75 ++++ pkg/epp/scheduling/types/types.go | 35 +- 15 files changed, 969 insertions(+), 246 deletions(-) create mode 100644 pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric create mode 100644 pkg/epp/scheduling/config/config.go rename pkg/epp/scheduling/{ => plugins}/filter.go (60%) rename pkg/epp/scheduling/{ => plugins}/filter_test.go (82%) create mode 100644 pkg/epp/scheduling/plugins/noop.go create mode 100644 pkg/epp/scheduling/plugins/picker.go create mode 100644 pkg/epp/scheduling/types/interfaces.go diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index 925a0cc5..21c0f401 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -79,6 +79,9 @@ func (p *Pod) String() string { } func (p *Pod) Clone() *Pod { + if p == nil { + return nil + } return &Pod{ NamespacedName: types.NamespacedName{ Name: p.NamespacedName.Name, @@ -118,6 +121,9 @@ func (m *Metrics) String() string { } func (m *Metrics) Clone() *Metrics { + if m == nil { + return nil + } cm := make(map[string]int, len(m.ActiveModels)) for k, v := range m.ActiveModels { cm[k] = v diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index 44537923..9121b59a 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -67,7 +67,7 @@ func (s *StreamingServer) HandleRequestBody( ResolvedTargetModel: modelName, Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, } - logger.V(logutil.DEBUG).Info("LLM request assembled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "critical", llmReq.Critical) + logger.V(logutil.DEBUG).Info("LLM request assembled", "request", llmReq) var err error // Update target models in the body. @@ -81,11 +81,11 @@ func (s *StreamingServer) HandleRequestBody( return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} } - target, err := s.scheduler.Schedule(ctx, llmReq) + res, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { return reqCtx, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} } - targetPod := target.GetPod() + targetPod := res.TargetPod.GetPod() // Insert target endpoint to instruct Envoy to route requests to the specified target pod. // Attach the port number @@ -96,8 +96,7 @@ func (s *StreamingServer) HandleRequestBody( endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) logger.V(logutil.DEFAULT).Info("Request handled", - "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod, "endpoint metrics", - fmt.Sprintf("%+v", target)) + "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) reqCtx.Model = llmReq.Model reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index f97e9ede..2e3a35fe 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -65,7 +65,7 @@ type StreamingServer struct { } type Scheduler interface { - Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (targetPod schedulingtypes.Pod, err error) + Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (result *schedulingtypes.Result, err error) } // RequestContext stores context information during the life time of an HTTP request. diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index b474df36..56dcfca8 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -30,6 +30,7 @@ import ( const ( InferenceModelComponent = "inference_model" InferencePoolComponent = "inference_pool" + EPPComponent = "endpoint_picker" ) var ( @@ -176,6 +177,20 @@ var ( }, []string{"name"}, ) + + // Scheduler Plugin Metrics + SchedulerPluginProcessingLatencies = compbasemetrics.NewHistogramVec( + &compbasemetrics.HistogramOpts{ + Subsystem: EPPComponent, + Name: "scheduler_plugin_duration_seconds", + Help: "Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name.", + Buckets: []float64{ + 0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, + }, + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"plugin_type", "plugin_name"}, + ) ) var registerMetrics sync.Once @@ -196,6 +211,8 @@ func Register() { legacyregistry.MustRegister(inferencePoolAvgKVCache) legacyregistry.MustRegister(inferencePoolAvgQueueSize) legacyregistry.MustRegister(inferencePoolReadyPods) + + legacyregistry.MustRegister(SchedulerPluginProcessingLatencies) }) } @@ -293,3 +310,8 @@ func RecordInferencePoolAvgQueueSize(name string, queueSize float64) { func RecordinferencePoolReadyPods(name string, runningPods float64) { inferencePoolReadyPods.WithLabelValues(name).Set(runningPods) } + +// RecordSchedulerPluginProcessingLatency records the processing latency for a scheduler plugin. +func RecordSchedulerPluginProcessingLatency(pluginType, pluginName string, duration time.Duration) { + SchedulerPluginProcessingLatencies.WithLabelValues(pluginType, pluginName).Observe(duration.Seconds()) +} diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index b5f19e6d..81797e6d 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -556,3 +556,67 @@ func TestInferencePoolMetrics(t *testing.T) { }) } } + +func TestSchedulerPluginProcessingLatencies(t *testing.T) { + type pluginLatency struct { + pluginType string + pluginName string + duration time.Duration + } + scenarios := []struct { + name string + latencies []pluginLatency + }{ + { + name: "multiple plugins", + latencies: []pluginLatency{ + { + pluginType: "PreSchedule", + pluginName: "PluginA", + duration: 100 * time.Millisecond, + }, + { + pluginType: "PostSchedule", + pluginName: "PluginB", + duration: 200 * time.Millisecond, + }, + { + pluginType: "Filter", + pluginName: "PluginC", + duration: 50 * time.Millisecond, + }, + { + pluginType: "Scorer", + pluginName: "PluginD", + duration: 10 * time.Millisecond, + }, + { + pluginType: "Picker", + pluginName: "PluginE", + duration: 10 * time.Microsecond, + }, + }, + }, + } + Register() + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + for _, latency := range scenario.latencies { + RecordSchedulerPluginProcessingLatency(latency.pluginType, latency.pluginName, latency.duration) + } + + wantPluginLatencies, err := os.Open("testdata/scheduler_plugin_processing_latencies_metric") + defer func() { + if err := wantPluginLatencies.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantPluginLatencies, "endpoint_picker_scheduler_plugin_processing_latencies"); err != nil { + t.Error(err) + } + }) + } +} diff --git a/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric b/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric new file mode 100644 index 00000000..8c11757f --- /dev/null +++ b/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric @@ -0,0 +1,67 @@ +# HELP endpoint_picker_scheduler_plugin_duration_seconds [ALPHA] Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name. +# TYPE endpoint_picker_scheduler_plugin_duration_seconds histogram +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.01"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.02"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.05"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.1"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="+Inf"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginA",plugin_type="PreSchedule"} 0.1 +endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginA",plugin_type="PreSchedule"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.01"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.02"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.05"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.1"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="+Inf"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginB",plugin_type="PostSchedule"} 0.2 +endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginB",plugin_type="PostSchedule"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.01"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.02"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.05"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.1"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="+Inf"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginC",plugin_type="Filter"} 0.05 +endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginC",plugin_type="Filter"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.001"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.002"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.005"} 0 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.01"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.02"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.05"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.1"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="+Inf"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginD",plugin_type="Scorer"} 0.01 +endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginD",plugin_type="Scorer"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0001"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0002"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0005"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.001"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.002"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.005"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.01"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.02"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.05"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.1"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="+Inf"} 1 +endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginE",plugin_type="Picker"} 1e-05 +endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginE",plugin_type="Picker"} 1 diff --git a/pkg/epp/scheduling/config/config.go b/pkg/epp/scheduling/config/config.go new file mode 100644 index 00000000..e00b82ae --- /dev/null +++ b/pkg/epp/scheduling/config/config.go @@ -0,0 +1,58 @@ +/* +Copyright 2025 The Kubernetes 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 config + +import ( + "sigs.k8s.io/controller-runtime/pkg/log" + envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// Config holds all the configuration values for the scheduler +type Config struct { + KVCacheThreshold float64 + QueueThresholdCritical int + QueueingThresholdLoRA int + LoraAffinityThreshold float64 +} + +const ( + // Default values to use if environment variables are not set + defaultKVCacheThreshold = 0.8 + defaultQueueThresholdCritical = 5 + defaultQueueingThresholdLoRA = 128 + defaultLoraAffinityThreshold = 0.999 +) + +// LoadConfig loads configuration from environment variables +func LoadConfig() Config { + // Use a default logger for initial configuration loading + baseLogger := log.Log.WithName("scheduling-config") + + config := Config{ + KVCacheThreshold: envutil.GetEnvFloat("KV_CACHE_THRESHOLD", defaultKVCacheThreshold, baseLogger), + QueueThresholdCritical: envutil.GetEnvInt("QUEUE_THRESHOLD_CRITICAL", defaultQueueThresholdCritical, baseLogger), + QueueingThresholdLoRA: envutil.GetEnvInt("QUEUING_THRESHOLD_LORA", defaultQueueingThresholdLoRA, baseLogger), + LoraAffinityThreshold: envutil.GetEnvFloat("LORA_AFFINITY_THRESHOLD", defaultLoraAffinityThreshold, baseLogger), + } + + baseLogger.V(logutil.DEFAULT).Info("Scheduler configuration loaded", "config", config) + + return config +} + +var Conf = LoadConfig() diff --git a/pkg/epp/scheduling/filter.go b/pkg/epp/scheduling/plugins/filter.go similarity index 60% rename from pkg/epp/scheduling/filter.go rename to pkg/epp/scheduling/plugins/filter.go index 99044e97..efcb6be1 100644 --- a/pkg/epp/scheduling/filter.go +++ b/pkg/epp/scheduling/plugins/filter.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package scheduling +package plugins import ( "errors" @@ -22,83 +22,80 @@ import ( "math/rand" "time" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -type Filter interface { - Name() string - Filter(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) -} - -type basicFilter struct { +type Filter struct { name string filter filterFunc } -func (bf *basicFilter) Name() string { +func (bf *Filter) Name() string { if bf == nil { return "nil" } return bf.name } -func (bf *basicFilter) Filter(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { +func (bf *Filter) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { loggerTrace := ctx.Logger.V(logutil.TRACE) loggerTrace.Info("Running a filter", "name", bf.Name(), "podCount", len(pods)) return bf.filter(ctx, pods) } -// decisionTreeFilter applies current filterFunc, and then recursively applies next filters +// DecisionTreeFilter applies current filterFunc, and then recursively applies next filters // depending success or failure of the current filter. // It can be used to construct a flow chart algorithm. -type decisionTreeFilter struct { - current Filter - // nextOnSuccess filter will be applied after successfully applying the current filter. +type DecisionTreeFilter struct { + Current types.Filter + // NextOnSuccess filter will be applied after successfully applying the current filter. // The filtered results will be passed to the next filter. - nextOnSuccess Filter - // nextOnFailure filter will be applied if current filter fails. + NextOnSuccess types.Filter + // NextOnFailure filter will be applied if current filter fails. // The original input will be passed to the next filter. - nextOnFailure Filter - // nextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the + NextOnFailure types.Filter + // NextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the // success or failure of the current filter. - // NOTE: When using nextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. + // NOTE: When using NextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. // However if that's not the case, nextOnSuccess and nextOnFailure will be used, instead of - // nextOnSuccessOrFailure, in the success and failure scenarios, respectively. - nextOnSuccessOrFailure Filter + // NextOnSuccessOrFailure, in the success and failure scenarios, respectively. + NextOnSuccessOrFailure types.Filter } -func (f *decisionTreeFilter) Name() string { +func (f *DecisionTreeFilter) Name() string { if f == nil { return "nil" } - return f.current.Name() + return f.Current.Name() } -func (f *decisionTreeFilter) Filter(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { +func (f *DecisionTreeFilter) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { loggerTrace := ctx.Logger.V(logutil.TRACE) - filtered, err := f.current.Filter(ctx, pods) + filtered, err := f.Current.Filter(ctx, pods) - next := f.nextOnSuccessOrFailure + next := f.NextOnSuccessOrFailure if err == nil && len(filtered) > 0 { - if f.nextOnSuccess == nil && f.nextOnSuccessOrFailure == nil { + if f.NextOnSuccess == nil && f.NextOnSuccessOrFailure == nil { // No succeeding filters to run, return. return filtered, err } - if f.nextOnSuccess != nil { - next = f.nextOnSuccess + if f.NextOnSuccess != nil { + next = f.NextOnSuccess } loggerTrace.Info("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filtered)) // On success, pass the filtered result to the next filter. return next.Filter(ctx, filtered) } else { - if f.nextOnFailure == nil && f.nextOnSuccessOrFailure == nil { + if f.NextOnFailure == nil && f.NextOnSuccessOrFailure == nil { // No succeeding filters to run, return. return filtered, err } - if f.nextOnFailure != nil { - next = f.nextOnFailure + if f.NextOnFailure != nil { + next = f.NextOnFailure } loggerTrace.Info("Filter failed", "filter", f.Name(), "next", next.Name()) // On failure, pass the initial set of pods to the next filter. @@ -107,12 +104,12 @@ func (f *decisionTreeFilter) Filter(ctx *types.Context, pods []*types.PodMetrics } // filterFunc filters a set of input pods to a subset. -type filterFunc func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) +type filterFunc func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) // toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. func toFilterFunc(pp podPredicate) filterFunc { - return func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { - filtered := []*types.PodMetrics{} + return func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { + filtered := []types.Pod{} for _, pod := range pods { pass := pp(ctx.Req, pod) if pass { @@ -126,7 +123,7 @@ func toFilterFunc(pp podPredicate) filterFunc { } } -var leastQueueFilter = &basicFilter{ +var LeastQueueFilter = &Filter{ name: "least queuing", filter: leastQueuingFilterFunc, } @@ -138,34 +135,34 @@ var leastQueueFilter = &basicFilter{ // the least one as it gives more choices for the next filter, which on aggregate gave better // results. // TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { +func leastQueuingFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { min := math.MaxInt max := 0 - filtered := []*types.PodMetrics{} + filtered := []types.Pod{} for _, pod := range pods { - if pod.WaitingQueueSize <= min { - min = pod.WaitingQueueSize + if pod.GetMetrics().WaitingQueueSize <= min { + min = pod.GetMetrics().WaitingQueueSize } - if pod.WaitingQueueSize >= max { - max = pod.WaitingQueueSize + if pod.GetMetrics().WaitingQueueSize >= max { + max = pod.GetMetrics().WaitingQueueSize } } for _, pod := range pods { - if pod.WaitingQueueSize >= min && pod.WaitingQueueSize <= min+(max-min)/len(pods) { + if pod.GetMetrics().WaitingQueueSize >= min && pod.GetMetrics().WaitingQueueSize <= min+(max-min)/len(pods) { filtered = append(filtered, pod) } } return filtered, nil } -var lowQueueFilter = &basicFilter{ +var LowQueueFilter = &Filter{ name: "low queueing filter", - filter: toFilterFunc((queueThresholdPredicate(config.QueueingThresholdLoRA))), + filter: toFilterFunc((queueThresholdPredicate(config.Conf.QueueingThresholdLoRA))), } -var leastKVCacheFilter = &basicFilter{ +var LeastKVCacheFilter = &Filter{ name: "least KV cache percent", filter: leastKVCacheFilterFunc, } @@ -176,29 +173,29 @@ var leastKVCacheFilter = &basicFilter{ // should consider them all instead of the absolute minimum one. This worked better than picking the // least one as it gives more choices for the next filter, which on aggregate gave better results. // TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { +func leastKVCacheFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { min := math.MaxFloat64 var max float64 = 0 - filtered := []*types.PodMetrics{} + filtered := []types.Pod{} for _, pod := range pods { - if pod.KVCacheUsagePercent <= min { - min = pod.KVCacheUsagePercent + if pod.GetMetrics().KVCacheUsagePercent <= min { + min = pod.GetMetrics().KVCacheUsagePercent } - if pod.KVCacheUsagePercent >= max { - max = pod.KVCacheUsagePercent + if pod.GetMetrics().KVCacheUsagePercent >= max { + max = pod.GetMetrics().KVCacheUsagePercent } } for _, pod := range pods { - if pod.KVCacheUsagePercent >= min && pod.KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { + if pod.GetMetrics().KVCacheUsagePercent >= min && pod.GetMetrics().KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { filtered = append(filtered, pod) } } return filtered, nil } -var loRAAffinityFilter = &basicFilter{ +var LoRAAffinityFilter = &Filter{ name: "affinity LoRA", filter: loRASoftAffinityFilterFunc, } @@ -219,20 +216,20 @@ var loRAAffinityFilter = &basicFilter{ // Returns: // - Filtered slice of pod metrics based on affinity and availability // - Error if any issues occur during filtering -func loRASoftAffinityFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { +func loRASoftAffinityFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { // Pre-allocate slices with estimated capacity - filtered_affinity := make([]*types.PodMetrics, 0, len(pods)) - filtered_available := make([]*types.PodMetrics, 0, len(pods)) + filtered_affinity := make([]types.Pod, 0, len(pods)) + filtered_available := make([]types.Pod, 0, len(pods)) // Categorize pods based on affinity and availability for _, pod := range pods { - _, active := pod.ActiveModels[ctx.Req.ResolvedTargetModel] - _, waiting := pod.WaitingModels[ctx.Req.ResolvedTargetModel] + _, active := pod.GetMetrics().ActiveModels[ctx.Req.ResolvedTargetModel] + _, waiting := pod.GetMetrics().WaitingModels[ctx.Req.ResolvedTargetModel] if active || waiting { filtered_affinity = append(filtered_affinity, pod) - } else if len(pod.ActiveModels)+len(pod.WaitingModels) < pod.MaxActiveModels { + } else if len(pod.GetMetrics().ActiveModels)+len(pod.GetMetrics().WaitingModels) < pod.GetMetrics().MaxActiveModels { filtered_available = append(filtered_available, pod) } } @@ -243,7 +240,7 @@ func loRASoftAffinityFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([ // If both groups have pods, use probability to select which group to return if len(filtered_affinity) > 0 && len(filtered_available) > 0 { - if randGen.Float64() < config.LoraAffinityThreshold { + if randGen.Float64() < config.Conf.LoraAffinityThreshold { return filtered_affinity, nil } return filtered_available, nil @@ -257,23 +254,38 @@ func loRASoftAffinityFilterFunc(ctx *types.Context, pods []*types.PodMetrics) ([ return filtered_available, nil } +var HasCapacityFilter = &Filter{ + name: "has capacity for sheddable requests", + filter: toFilterFunc(queueThresholdPredicate(config.Conf.QueueThresholdCritical).and(kvCacheThresholdPredicate(config.Conf.KVCacheThreshold))), +} + +var DropRequestFilter = &Filter{ + name: "drop request", + filter: func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { + ctx.Logger.V(logutil.DEFAULT).Info("Request dropped", "request", ctx.Req) + return []types.Pod{}, errutil.Error{ + Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", + } + }, +} + // podPredicate is a filter function to check whether a pod is desired. -type podPredicate func(req *types.LLMRequest, pod *types.PodMetrics) bool +type podPredicate func(req *types.LLMRequest, pod types.Pod) bool func queueThresholdPredicate(queueThreshold int) podPredicate { - return func(req *types.LLMRequest, pod *types.PodMetrics) bool { - return pod.WaitingQueueSize <= queueThreshold + return func(req *types.LLMRequest, pod types.Pod) bool { + return pod.GetMetrics().WaitingQueueSize <= queueThreshold } } func kvCacheThresholdPredicate(kvCacheThreshold float64) podPredicate { - return func(req *types.LLMRequest, pod *types.PodMetrics) bool { - return pod.KVCacheUsagePercent <= kvCacheThreshold + return func(req *types.LLMRequest, pod types.Pod) bool { + return pod.GetMetrics().KVCacheUsagePercent <= kvCacheThreshold } } func (pp podPredicate) and(another podPredicate) podPredicate { - return func(req *types.LLMRequest, pod *types.PodMetrics) bool { + return func(req *types.LLMRequest, pod types.Pod) bool { return pp(req, pod) && another(req, pod) } } diff --git a/pkg/epp/scheduling/filter_test.go b/pkg/epp/scheduling/plugins/filter_test.go similarity index 82% rename from pkg/epp/scheduling/filter_test.go rename to pkg/epp/scheduling/plugins/filter_test.go index 543826d0..107b423f 100644 --- a/pkg/epp/scheduling/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package scheduling +package plugins import ( "context" @@ -24,6 +24,7 @@ import ( "github.com/google/go-cmp/cmp" k8stypes "k8s.io/apimachinery/pkg/types" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -31,17 +32,17 @@ func TestFilter(t *testing.T) { tests := []struct { name string req *types.LLMRequest - input []*types.PodMetrics - output []*types.PodMetrics + input []types.Pod + output []types.Pod err bool - filter *decisionTreeFilter + filter *DecisionTreeFilter }{ { name: "simple filter without successor, failure", - filter: &decisionTreeFilter{ - current: &basicFilter{ + filter: &DecisionTreeFilter{ + Current: &Filter{ name: "error", - filter: func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { + filter: func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { return nil, errors.New("filter error") }, }, @@ -58,7 +59,8 @@ func TestFilter(t *testing.T) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got); diff != "" { + opt := cmp.AllowUnexported(types.PodMetrics{}) + if diff := cmp.Diff(test.output, got, opt); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -70,43 +72,43 @@ func TestFilterFunc(t *testing.T) { name string f filterFunc req *types.LLMRequest - input []*types.PodMetrics - output []*types.PodMetrics + input []types.Pod + output []types.Pod err bool }{ { name: "least queuing empty input", f: leastQueuingFilterFunc, - input: []*types.PodMetrics{}, - output: []*types.PodMetrics{}, + input: []types.Pod{}, + output: []types.Pod{}, }, { name: "least queuing", f: leastQueuingFilterFunc, - input: []*types.PodMetrics{ - { + input: []types.Pod{ + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, }, }, - { + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, }, }, - { + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, }, }, }, - output: []*types.PodMetrics{ - { + output: []types.Pod{ + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, }, }, - { + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, }, @@ -116,36 +118,36 @@ func TestFilterFunc(t *testing.T) { { name: "least kv cache empty input", f: leastKVCacheFilterFunc, - input: []*types.PodMetrics{}, - output: []*types.PodMetrics{}, + input: []types.Pod{}, + output: []types.Pod{}, }, { name: "least kv cache", f: leastKVCacheFilterFunc, - input: []*types.PodMetrics{ - { + input: []types.Pod{ + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0, }, }, - { + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0.3, }, }, - { + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 1.0, }, }, }, - output: []*types.PodMetrics{ - { + output: []types.Pod{ + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0, }, }, - { + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ KVCacheUsagePercent: 0.3, }, @@ -155,22 +157,22 @@ func TestFilterFunc(t *testing.T) { { name: "lowQueueAndLessThanKVCacheThresholdPredicate", f: toFilterFunc(queueThresholdPredicate(0).and(kvCacheThresholdPredicate(0.8))), - input: []*types.PodMetrics{ - { + input: []types.Pod{ + &types.PodMetrics{ // This pod should be returned. Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, }, - { + &types.PodMetrics{ // Queue is non zero, despite low kv cache, should not return. Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.3, }, }, - { + &types.PodMetrics{ // High kv cache despite zero queue, should not return Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, @@ -178,8 +180,8 @@ func TestFilterFunc(t *testing.T) { }, }, }, - output: []*types.PodMetrics{ - { + output: []types.Pod{ + &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, @@ -197,7 +199,8 @@ func TestFilterFunc(t *testing.T) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got); diff != "" { + opt := cmp.AllowUnexported(types.PodMetrics{}) + if diff := cmp.Diff(test.output, got, opt); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -215,15 +218,15 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { ) // Save original config value to restore later - originalThreshold := config.LoraAffinityThreshold + originalThreshold := config.Conf.LoraAffinityThreshold // Set a specific test value for this test testThreshold := 0.75 // 75% - config.LoraAffinityThreshold = testThreshold + config.Conf.LoraAffinityThreshold = testThreshold // Ensure we restore the original threshold when test completes defer func() { - config.LoraAffinityThreshold = originalThreshold + config.Conf.LoraAffinityThreshold = originalThreshold }() // Create a test request and pods @@ -233,8 +236,8 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { } // Test setup: One affinity pod and one available pod - pods := []*types.PodMetrics{ - { + pods := []types.Pod{ + &types.PodMetrics{ Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "affinity-pod"}}, Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, @@ -243,7 +246,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, }, }, - { + &types.PodMetrics{ Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "available-pod"}}, Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, @@ -258,7 +261,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { availableCount := 0 // Use the test threshold value - expectedAffinityPercent := config.LoraAffinityThreshold * 100 + expectedAffinityPercent := config.Conf.LoraAffinityThreshold * 100 expectedAvailabilityPercent := 100 - expectedAffinityPercent for i := 0; i < numIterations; i++ { @@ -292,8 +295,8 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { availableUpperBound := expectedAvailabilityPercent + tolerancePercent t.Logf("Distribution results over %d iterations:", numIterations) - t.Logf("Expected affinity percent: %.2f%% (threshold: %.2f)", expectedAffinityPercent, config.LoraAffinityThreshold) - t.Logf("Expected availability percent: %.2f%% (threshold: %.2f)", expectedAvailabilityPercent, config.LoraAffinityThreshold) + t.Logf("Expected affinity percent: %.2f%% (threshold: %.2f)", expectedAffinityPercent, config.Conf.LoraAffinityThreshold) + t.Logf("Expected availability percent: %.2f%% (threshold: %.2f)", expectedAvailabilityPercent, config.Conf.LoraAffinityThreshold) t.Logf("Actual affinity percent: %.2f%% (%d out of %d)", actualAffinityPercent, affinityCount, numIterations) t.Logf("Actual available percent: %.2f%% (%d out of %d)", actualAvailablePercent, availableCount, numIterations) diff --git a/pkg/epp/scheduling/plugins/noop.go b/pkg/epp/scheduling/plugins/noop.go new file mode 100644 index 00000000..1abcb95b --- /dev/null +++ b/pkg/epp/scheduling/plugins/noop.go @@ -0,0 +1,38 @@ +/* +Copyright 2025 The Kubernetes 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 plugins + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// NoopPlugin provides a default, no-operation implementation of the Plugin interface. +// It can be embedded in other plugin implementations to avoid boilerplate code for +// unused methods. +type NoopPlugin struct{} + +func (p *NoopPlugin) Name() string { return "NoopPlugin" } + +func (p *NoopPlugin) Score(ctx *types.Context, pod types.Pod) (float64, error) { return 0.0, nil } + +func (p *NoopPlugin) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { + return pods, nil +} + +func (p *NoopPlugin) PreSchedule(ctx *types.Context) {} + +func (p *NoopPlugin) PostSchedule(ctx *types.Context, res *types.Result) {} diff --git a/pkg/epp/scheduling/plugins/picker.go b/pkg/epp/scheduling/plugins/picker.go new file mode 100644 index 00000000..569e4e86 --- /dev/null +++ b/pkg/epp/scheduling/plugins/picker.go @@ -0,0 +1,37 @@ +/* +Copyright 2025 The Kubernetes 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 plugins + +import ( + "fmt" + "math/rand" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +type RandomPicker struct{} + +func (rp *RandomPicker) Name() string { + return "random" +} + +func (rp *RandomPicker) Pick(ctx *types.Context, pods []types.Pod) (*types.Result, error) { + ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) + i := rand.Intn(len(pods)) + return &types.Result{TargetPod: pods[i]}, nil +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 8679ffba..7cc2bd96 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -20,113 +20,71 @@ package scheduling import ( "context" "fmt" - "math/rand" + "time" "sigs.k8s.io/controller-runtime/pkg/log" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" - envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -// Config holds all the configuration values for the scheduler -type Config struct { - KVCacheThreshold float64 - QueueThresholdCritical int - QueueingThresholdLoRA int - LoraAffinityThreshold float64 -} - -const ( - // Default values to use if environment variables are not set - defaultKVCacheThreshold = 0.8 - defaultQueueThresholdCritical = 5 - defaultQueueingThresholdLoRA = 128 - defaultLoraAffinityThreshold = 0.999 -) - -// LoadConfig loads configuration from environment variables -func LoadConfig() Config { - // Use a default logger for initial configuration loading - baseLogger := log.Log.WithName("scheduling-config") - - config := Config{ - KVCacheThreshold: envutil.GetEnvFloat("KV_CACHE_THRESHOLD", defaultKVCacheThreshold, baseLogger), - QueueThresholdCritical: envutil.GetEnvInt("QUEUE_THRESHOLD_CRITICAL", defaultQueueThresholdCritical, baseLogger), - QueueingThresholdLoRA: envutil.GetEnvInt("QUEUING_THRESHOLD_LORA", defaultQueueingThresholdLoRA, baseLogger), - LoraAffinityThreshold: envutil.GetEnvFloat("LORA_AFFINITY_THRESHOLD", defaultLoraAffinityThreshold, baseLogger), - } - - baseLogger.V(logutil.DEFAULT).Info("Scheduler configuration loaded", "config", config) - - return config -} - -var config = LoadConfig() - var ( - lowLatencyFilter = &decisionTreeFilter{ - current: lowQueueFilter, - nextOnSuccess: &decisionTreeFilter{ - current: loRAAffinityFilter, - nextOnSuccessOrFailure: &decisionTreeFilter{ - current: leastQueueFilter, - nextOnSuccessOrFailure: &decisionTreeFilter{ - current: leastKVCacheFilter, + lowLatencyFilter = &plugins.DecisionTreeFilter{ + Current: plugins.LowQueueFilter, + NextOnSuccess: &plugins.DecisionTreeFilter{ + Current: plugins.LoRAAffinityFilter, + NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ + Current: plugins.LeastQueueFilter, + NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ + Current: plugins.LeastKVCacheFilter, }, }, }, - nextOnFailure: &decisionTreeFilter{ - current: leastQueueFilter, - nextOnSuccessOrFailure: &decisionTreeFilter{ - current: loRAAffinityFilter, - nextOnSuccessOrFailure: &decisionTreeFilter{ - current: leastKVCacheFilter, + NextOnFailure: &plugins.DecisionTreeFilter{ + Current: plugins.LeastQueueFilter, + NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ + Current: plugins.LoRAAffinityFilter, + NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ + Current: plugins.LeastKVCacheFilter, }, }, }, } - sheddableRequestFilter = &decisionTreeFilter{ + sheddableRequestFilter = &plugins.DecisionTreeFilter{ // When there is at least one model server that's not queuing requests, and still has KV // cache below a certain threshold, we consider this model server has capacity to handle // a sheddable request without impacting critical requests. - current: hasCapacityFilter, - nextOnSuccess: lowLatencyFilter, + Current: plugins.HasCapacityFilter, + NextOnSuccess: lowLatencyFilter, // If all pods are queuing or running above the KVCache threshold, we drop the sheddable // request to make room for critical requests. - nextOnFailure: dropRequestFilter, - } - - hasCapacityFilter = &basicFilter{ - name: "has capacity for sheddable requests", - filter: toFilterFunc(queueThresholdPredicate(config.QueueThresholdCritical).and(kvCacheThresholdPredicate(config.KVCacheThreshold))), - } - - dropRequestFilter = &basicFilter{ - name: "drop request", - filter: func(ctx *types.Context, pods []*types.PodMetrics) ([]*types.PodMetrics, error) { - ctx.Logger.V(logutil.DEFAULT).Info("Request dropped", "request", ctx.Req) - return []*types.PodMetrics{}, errutil.Error{ - Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", - } - }, + NextOnFailure: plugins.DropRequestFilter, } ) func NewScheduler(datastore Datastore) *Scheduler { + defaultPlugin := &defaultPlugin{} + return &Scheduler{ - datastore: datastore, - criticalRequestFilter: lowLatencyFilter, - sheddableRequestFilter: sheddableRequestFilter, + datastore: datastore, + preSchedulePlugins: []types.PreSchedule{}, + postSchedulePlugins: []types.PostSchedule{}, + scorers: []types.Scorer{}, + filters: []types.Filter{defaultPlugin}, + picker: defaultPlugin, } } type Scheduler struct { - datastore Datastore - criticalRequestFilter Filter - sheddableRequestFilter Filter + datastore Datastore + preSchedulePlugins []types.PreSchedule + postSchedulePlugins []types.PostSchedule + filters []types.Filter + scorers []types.Scorer + picker types.Picker } type Datastore interface { @@ -134,27 +92,125 @@ type Datastore interface { } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (targetPod types.Pod, err error) { +func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types.Result, error) { logger := log.FromContext(ctx).WithValues("request", req) + loggerDebug := logger.V(logutil.DEBUG) // Snapshot pod metrics from the datastore to: // 1. Reduce concurrent access to the datastore. // 2. Ensure consistent data during the scheduling operation of a request. sCtx := types.NewContext(ctx, req, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) - logger.V(logutil.DEBUG).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", sCtx.PodsSnapshot)) + loggerDebug.Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", sCtx.PodsSnapshot)) - var filter Filter - if req.Critical { - filter = s.criticalRequestFilter - } else { - filter = s.sheddableRequestFilter + s.runPreSchedulePlugins(sCtx) + + pods, err := s.runFilterPlugins(sCtx) + if err != nil { + return nil, err + } + + if err := s.runScorerPlugins(sCtx, pods); err != nil { + return nil, err + } + + before := time.Now() + res, err := s.picker.Pick(sCtx, pods) + metrics.RecordSchedulerPluginProcessingLatency(types.PickerPluginType, s.picker.Name(), time.Since(before)) + if err != nil { + return nil, err } + loggerDebug.Info("After running picker plugins", "result", res) - pods, err := filter.Filter(sCtx, sCtx.PodsSnapshot) - if err != nil || len(pods) == 0 { - return nil, fmt.Errorf("failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) + s.runPostSchedulePlugins(sCtx, res) + + return res, nil +} + +func (s *Scheduler) runPreSchedulePlugins(ctx *types.Context) { + for _, plugin := range s.preSchedulePlugins { + ctx.Logger.V(logutil.DEBUG).Info("Running pre-schedule plugin", "plugin", plugin.Name()) + before := time.Now() + plugin.PreSchedule(ctx) + metrics.RecordSchedulerPluginProcessingLatency(types.PreSchedulerPluginType, plugin.Name(), time.Since(before)) + } +} + +func (s *Scheduler) runPostSchedulePlugins(ctx *types.Context, res *types.Result) { + for _, plugin := range s.postSchedulePlugins { + ctx.Logger.V(logutil.DEBUG).Info("Running post-schedule plugin", "plugin", plugin.Name()) + before := time.Now() + plugin.PostSchedule(ctx, res) + metrics.RecordSchedulerPluginProcessingLatency(types.PostSchedulePluginType, plugin.Name(), time.Since(before)) + } +} + +func (s *Scheduler) runFilterPlugins(ctx *types.Context) ([]types.Pod, error) { + loggerDebug := ctx.Logger.V(logutil.DEBUG) + pods := ctx.PodsSnapshot + loggerDebug.Info("Before running filter plugins", "pods", pods) + for _, filter := range s.filters { + loggerDebug.Info("Running filter plugin", "plugin", filter.Name()) + before := time.Now() + filteredPods, err := filter.Filter(ctx, pods) + metrics.RecordSchedulerPluginProcessingLatency(types.FilterPluginType, filter.Name(), time.Since(before)) + if err != nil || len(filteredPods) == 0 { + return nil, fmt.Errorf("failed to apply filter, resulted %v pods, this should never happen: %w", len(filteredPods), err) + } + pods = filteredPods + loggerDebug.Info("Filter plugin result", "plugin", filter.Name(), "pods", pods) + } + loggerDebug.Info("After running filter plugins", "pods", pods) + return pods, nil +} + +func (s *Scheduler) runScorerPlugins(ctx *types.Context, pods []types.Pod) error { + loggerDebug := ctx.Logger.V(logutil.DEBUG) + loggerDebug.Info("Before running score plugins", "pods", pods) + for _, pod := range pods { + score, err := runScorersForPod(ctx, s.scorers, pod) + if err != nil { + return err + } + pod.SetScore(score) + } + loggerDebug.Info("After running score plugins", "pods", pods) + return nil +} + +// Iterate through each scorer in the chain and accumulate the scores. +func runScorersForPod(ctx *types.Context, scorers []types.Scorer, pod types.Pod) (float64, error) { + logger := ctx.Logger.WithValues("pod", pod.GetPod().NamespacedName).V(logutil.DEBUG) + score := float64(0) + for _, scorer := range scorers { + logger.Info("Running scorer", "scorer", scorer.Name()) + before := time.Now() + oneScore, err := scorer.Score(ctx, pod) + metrics.RecordSchedulerPluginProcessingLatency(types.ScorerPluginType, scorer.Name(), time.Since(before)) + if err != nil { + logger.Error(err, "Failed to calculate score for scorer", "scorer", scorer.Name()) + return 0, err + } + score += oneScore + logger.Info("After scorer", "scorer", scorer.Name(), "score", oneScore, "total score", score) + } + return score, nil +} + +type defaultPlugin struct { + plugins.RandomPicker +} + +func (p *defaultPlugin) Name() string { + return "DefaultPlugin" +} + +func (p *defaultPlugin) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { + req := ctx.Req + var filter types.Filter + if req.Critical { + filter = lowLatencyFilter + } else { + filter = sheddableRequestFilter } - logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) - i := rand.Intn(len(pods)) - return pods[i], nil + return filter.Filter(ctx, pods) } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 3fd3fb24..5a2265bf 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -18,22 +18,34 @@ package scheduling import ( "context" + "errors" "testing" "github.com/google/go-cmp/cmp" k8stypes "k8s.io/apimachinery/pkg/types" - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) +// Tests the default scheduler configuration and expected behavior. func TestSchedule(t *testing.T) { tests := []struct { - name string - req *types.LLMRequest - input []*backendmetrics.FakePodMetrics - output types.Pod - err bool + name string + req *types.LLMRequest + input []*backendmetrics.FakePodMetrics + wantRes *types.Result + err bool }{ + { + name: "no pods in datastore", + req: &types.LLMRequest{ + Model: "any-model", + ResolvedTargetModel: "any-model", + Critical: true, + }, + input: []*backendmetrics.FakePodMetrics{}, + err: true, + }, { name: "critical request", req: &types.LLMRequest{ @@ -80,17 +92,19 @@ func TestSchedule(t *testing.T) { }, }, }, - output: &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.1, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, + wantRes: &types.Result{ + TargetPod: &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.1, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + WaitingModels: map[string]int{}, }, - WaitingModels: map[string]int{}, }, }, }, @@ -139,17 +153,19 @@ func TestSchedule(t *testing.T) { }, }, }, - output: &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, + wantRes: &types.Result{ + TargetPod: &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + WaitingModels: map[string]int{}, }, - WaitingModels: map[string]int{}, }, }, }, @@ -199,8 +215,8 @@ func TestSchedule(t *testing.T) { }, }, }, - output: nil, - err: true, + wantRes: nil, + err: true, }, } @@ -212,13 +228,205 @@ func TestSchedule(t *testing.T) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - if diff := cmp.Diff(test.output, got); diff != "" { + opt := cmp.AllowUnexported(types.PodMetrics{}) + if diff := cmp.Diff(test.wantRes, got, opt); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) } } +func TestSchedulePlugins(t *testing.T) { + tp1 := &TestPlugin{ + NameRes: "test1", + ScoreRes: 0.3, + FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}, {Name: "pod3"}}, + } + tp2 := &TestPlugin{ + NameRes: "test2", + ScoreRes: 0.8, + FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}}, + } + tpFilterErr := &TestPlugin{ + NameRes: "filter err", + FilterErr: errors.New("filter error"), + } + tpScorerErr := &TestPlugin{ + NameRes: "score err", + ScoreErr: errors.New("score err"), + } + pickerPlugin := &TestPlugin{ + NameRes: "picker", + PickRes: k8stypes.NamespacedName{Name: "pod1"}, + } + pickerErr := &TestPlugin{ + NameRes: "picker err", + PickErr: errors.New("picker err"), + } + + tests := []struct { + name string + preSchedulePlugins []types.PreSchedule + postSchedulePlugins []types.PostSchedule + filters []types.Filter + scorers []types.Scorer + picker types.Picker + input []*backendmetrics.FakePodMetrics + wantTargetPod k8stypes.NamespacedName + targetPodScore float64 + // Number of expected pods to score (after filter) + numPodsToScore int + err bool + }{ + { + name: "all plugins executed successfully", + preSchedulePlugins: []types.PreSchedule{tp1, tp2}, + postSchedulePlugins: []types.PostSchedule{tp1, tp2}, + filters: []types.Filter{tp1, tp2}, + scorers: []types.Scorer{tp1, tp2}, + picker: pickerPlugin, + input: []*backendmetrics.FakePodMetrics{ + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, + targetPodScore: 1.1, + numPodsToScore: 2, + err: false, + }, + { + name: "filter error", + preSchedulePlugins: []types.PreSchedule{tp1, tp2}, + postSchedulePlugins: []types.PostSchedule{tp1, tp2}, + filters: []types.Filter{tp1, tpFilterErr}, + scorers: []types.Scorer{tp1, tp2}, + picker: pickerPlugin, + input: []*backendmetrics.FakePodMetrics{ + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + err: true, + }, + { + name: "scorer error", + preSchedulePlugins: []types.PreSchedule{tp1, tp2}, + postSchedulePlugins: []types.PostSchedule{tp1, tp2}, + filters: []types.Filter{tp1, tp2}, + scorers: []types.Scorer{tp1, tpScorerErr}, + picker: pickerPlugin, + input: []*backendmetrics.FakePodMetrics{ + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + err: true, + }, + { + name: "picker error", + preSchedulePlugins: []types.PreSchedule{tp1, tp2}, + postSchedulePlugins: []types.PostSchedule{tp1, tp2}, + filters: []types.Filter{tp1, tp2}, + scorers: []types.Scorer{tp1, tp2}, + picker: pickerErr, + input: []*backendmetrics.FakePodMetrics{ + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + err: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Reset all plugins before each new test case. + for _, plugin := range test.preSchedulePlugins { + plugin.(*TestPlugin).Reset() + } + for _, plugin := range test.postSchedulePlugins { + plugin.(*TestPlugin).Reset() + } + for _, plugin := range test.filters { + plugin.(*TestPlugin).Reset() + } + for _, plugin := range test.scorers { + plugin.(*TestPlugin).Reset() + } + test.picker.(*TestPlugin).Reset() + + // Initialize the scheduler + scheduler := &Scheduler{ + datastore: &fakeDataStore{pods: test.input}, + preSchedulePlugins: test.preSchedulePlugins, + postSchedulePlugins: test.postSchedulePlugins, + filters: test.filters, + scorers: test.scorers, + picker: test.picker, + } + + req := &types.LLMRequest{Model: "test-model"} + got, err := scheduler.Schedule(context.Background(), req) + + // Validate error state + if test.err != (err != nil) { + t.Fatalf("Unexpected error, got %v, want %v", err, test.err) + } + + if err != nil { + return + } + + // Validate output + opt := cmp.AllowUnexported(types.PodMetrics{}) + wantPod := &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: test.wantTargetPod}, + } + wantPod.SetScore(test.targetPodScore) + wantRes := &types.Result{TargetPod: wantPod} + if diff := cmp.Diff(wantRes, got, opt); diff != "" { + t.Errorf("Unexpected output (-want +got): %v", diff) + } + + // Validate plugin execution counts dynamically + for _, plugin := range test.preSchedulePlugins { + tp, _ := plugin.(*TestPlugin) + if tp.PreScheduleCallCount != 1 { + t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", tp.NameRes, tp.PreScheduleCallCount) + } + } + + for _, plugin := range test.postSchedulePlugins { + tp, _ := plugin.(*TestPlugin) + if tp.PostScheduleCallCount != 1 { + t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", tp.NameRes, tp.PostScheduleCallCount) + } + } + + for _, plugin := range test.filters { + tp, _ := plugin.(*TestPlugin) + if tp.FilterCallCount != 1 { + t.Errorf("Plugin %s Filter() called %d times, expected 1", tp.NameRes, tp.FilterCallCount) + } + } + + for _, plugin := range test.scorers { + tp, _ := plugin.(*TestPlugin) + if tp.ScoreCallCount != test.numPodsToScore { + t.Errorf("Plugin %s Score() called %d times, expected 1", tp.NameRes, tp.ScoreCallCount) + } + } + + tp, _ := test.picker.(*TestPlugin) + if tp.PickCallCount != 1 { + t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.NameRes, tp.PickCallCount) + } + + }) + } +} + type fakeDataStore struct { pods []*backendmetrics.FakePodMetrics } @@ -230,3 +438,68 @@ func (fds *fakeDataStore) PodGetAll() []backendmetrics.PodMetrics { } return pm } + +// TestPlugin is an implementation useful in unit tests. +type TestPlugin struct { + NameRes string + ScoreCallCount int + ScoreRes float64 + ScoreErr error + FilterCallCount int + FilterRes []k8stypes.NamespacedName + FilterErr error + PreScheduleCallCount int + PostScheduleCallCount int + PickCallCount int + PickRes k8stypes.NamespacedName + PickErr error +} + +func (tp *TestPlugin) Name() string { return tp.NameRes } + +func (tp *TestPlugin) Score(ctx *types.Context, pod types.Pod) (float64, error) { + tp.ScoreCallCount++ + return tp.ScoreRes, tp.ScoreErr +} + +func (tp *TestPlugin) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { + tp.FilterCallCount++ + return findPods(ctx, tp.FilterRes...), tp.FilterErr +} + +func (tp *TestPlugin) PreSchedule(ctx *types.Context) { + tp.PreScheduleCallCount++ +} + +func (tp *TestPlugin) PostSchedule(ctx *types.Context, res *types.Result) { + tp.PostScheduleCallCount++ +} + +func (tp *TestPlugin) Pick(ctx *types.Context, pods []types.Pod) (*types.Result, error) { + tp.PickCallCount++ + if tp.PickErr != nil { + return nil, tp.PickErr + } + pod := findPods(ctx, tp.PickRes)[0] + return &types.Result{TargetPod: pod}, nil +} + +func (tp *TestPlugin) Reset() { + tp.PreScheduleCallCount = 0 + tp.PostScheduleCallCount = 0 + tp.FilterCallCount = 0 + tp.ScoreCallCount = 0 + tp.PickCallCount = 0 +} + +func findPods(ctx *types.Context, names ...k8stypes.NamespacedName) []types.Pod { + res := []types.Pod{} + for _, pod := range ctx.PodsSnapshot { + for _, name := range names { + if pod.GetPod().NamespacedName.String() == name.String() { + res = append(res, pod) + } + } + } + return res +} diff --git a/pkg/epp/scheduling/types/interfaces.go b/pkg/epp/scheduling/types/interfaces.go new file mode 100644 index 00000000..6e954cef --- /dev/null +++ b/pkg/epp/scheduling/types/interfaces.go @@ -0,0 +1,75 @@ +/* +Copyright 2025 The Kubernetes 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 types + +import ( + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" +) + +const ( + PreSchedulerPluginType = "PreSchedule" + PostSchedulePluginType = "PostSchedule" + FilterPluginType = "Filter" + ScorerPluginType = "Scorer" + PickerPluginType = "Picker" +) + +type Pod interface { + GetPod() *backendmetrics.Pod + GetMetrics() *backendmetrics.Metrics + SetScore(float64) + Score() float64 + String() string +} + +// Plugin defines the interface for scheduler plugins, combining scoring, filtering, +// and event handling capabilities. +type Plugin interface { + // Name returns the name of the plugin. + Name() string +} + +// PreSchedule is called when the scheduler receives a new request. It can be used for various +// initialization work. +type PreSchedule interface { + Plugin + PreSchedule(ctx *Context) +} + +// PostSchedule is called by the scheduler after it selects a targetPod for the request. +type PostSchedule interface { + Plugin + PostSchedule(ctx *Context, res *Result) +} + +// Filter defines the interface for filtering a list of pods based on context. +type Filter interface { + Plugin + Filter(ctx *Context, pods []Pod) ([]Pod, error) +} + +// Scorer defines the interface for scoring pods based on context. +type Scorer interface { + Plugin + Score(ctx *Context, pod Pod) (float64, error) +} + +// Picker picks the final pod(s) to send the request to. +type Picker interface { + Plugin + Pick(ctx *Context, pods []Pod) (*Result, error) +} diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index 9450652e..e52e9047 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -30,23 +30,22 @@ type LLMRequest struct { Model string // Target models is a map of target model name to weight. TargetModels map[string]int + Prompt string // Resolved target model is the final target model after traffic split. ResolvedTargetModel string Critical bool } +func (r *LLMRequest) String() string { + return fmt.Sprintf("Model: %s, TargetModels: %v, ResolvedTargetModel: %s, Critical: %t, PromptLength: %v", r.Model, r.TargetModels, r.ResolvedTargetModel, r.Critical, len(r.Prompt)) +} + // Context holds contextual information during a scheduling operation. type Context struct { context.Context Logger logr.Logger Req *LLMRequest - PodsSnapshot []*PodMetrics -} - -type Pod interface { - GetPod() *backendmetrics.Pod - GetMetrics() *backendmetrics.Metrics - String() string + PodsSnapshot []Pod } func (pm *PodMetrics) String() string { @@ -64,12 +63,21 @@ func (pm *PodMetrics) GetMetrics() *backendmetrics.Metrics { return pm.Metrics } +func (pm *PodMetrics) SetScore(score float64) { + pm.score = score +} + +func (pm *PodMetrics) Score() float64 { + return pm.score +} + type PodMetrics struct { + score float64 *backendmetrics.Pod *backendmetrics.Metrics } -func NewContext(ctx context.Context, req *LLMRequest, pods []*PodMetrics) *Context { +func NewContext(ctx context.Context, req *LLMRequest, pods []Pod) *Context { logger := log.FromContext(ctx).WithValues("request", req) return &Context{ Context: ctx, @@ -79,10 +87,15 @@ func NewContext(ctx context.Context, req *LLMRequest, pods []*PodMetrics) *Conte } } -func ToSchedulerPodMetrics(pods []backendmetrics.PodMetrics) []*PodMetrics { - pm := make([]*PodMetrics, 0, len(pods)) +func ToSchedulerPodMetrics(pods []backendmetrics.PodMetrics) []Pod { + pm := make([]Pod, 0, len(pods)) for _, pod := range pods { - pm = append(pm, &PodMetrics{pod.GetPod().Clone(), pod.GetMetrics().Clone()}) + pm = append(pm, &PodMetrics{Pod: pod.GetPod().Clone(), Metrics: pod.GetMetrics().Clone()}) } return pm } + +// Result captures the scheduler result. +type Result struct { + TargetPod Pod +} From 7d238dd720303393c31138db8501225e86c77233 Mon Sep 17 00:00:00 2001 From: Nicole Xin Date: Tue, 22 Apr 2025 17:51:42 -0700 Subject: [PATCH 232/260] Complete the InferencePool documentation (#673) * Initial guide for inference pool * Add extensionReference to the InferencePool spec * Fix list formatting * Remove unused labels * Autogenerate the spec * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Rename llm-pool names in rollout example * Add use cases for replacing an inference pool * Rewording the background section * Create replacing-inference-pool.md * Replace instructions with a link for how to replace an inference pool * Update replacing-inference-pool.md * Update mkdocs.yml * Update replacing-inference-pool.md * Update inferencemodel_types.go * Update inferencepool.md * Update site-src/guides/replacing-inference-pool.md Co-authored-by: Rob Scott --------- Co-authored-by: Rob Scott --- api/v1alpha2/inferencemodel_types.go | 2 +- mkdocs.yml | 1 + site-src/api-types/inferencepool.md | 58 +++- site-src/guides/replacing-inference-pool.md | 59 ++++ site-src/reference/spec.md | 288 +++++++++++++++++--- 5 files changed, 352 insertions(+), 56 deletions(-) create mode 100644 site-src/guides/replacing-inference-pool.md diff --git a/api/v1alpha2/inferencemodel_types.go b/api/v1alpha2/inferencemodel_types.go index 052683d8..7cd98a74 100644 --- a/api/v1alpha2/inferencemodel_types.go +++ b/api/v1alpha2/inferencemodel_types.go @@ -126,7 +126,7 @@ type PoolObjectReference struct { } // Criticality defines how important it is to serve the model compared to other models. -// Criticality is intentionally a bounded enum to contain the possibilities that need to be supported by the load balancing algorithm. Any reference to the Criticality field must be optional(use a pointer), and set no default. +// Criticality is intentionally a bounded enum to contain the possibilities that need to be supported by the load balancing algorithm. Any reference to the Criticality field must be optional (use a pointer), and set no default. // This allows us to union this with a oneOf field in the future should we wish to adjust/extend this behavior. // +kubebuilder:validation:Enum=Critical;Standard;Sheddable type Criticality string diff --git a/mkdocs.yml b/mkdocs.yml index bdfffe05..e5927ed5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -63,6 +63,7 @@ nav: - Getting started: guides/index.md - Adapter Rollout: guides/adapter-rollout.md - Metrics: guides/metrics.md + - Replacing an Inference Pool: guides/replacing-inference-pool.md - Implementer's Guide: guides/implementers.md - Performance: - Benchmark: performance/benchmark/index.md diff --git a/site-src/api-types/inferencepool.md b/site-src/api-types/inferencepool.md index baa604b6..1494d314 100644 --- a/site-src/api-types/inferencepool.md +++ b/site-src/api-types/inferencepool.md @@ -7,28 +7,56 @@ ## Background -The InferencePool resource is a logical grouping of compute resources, e.g. Pods, that run model servers. The InferencePool would deploy its own routing, and offer administrative configuration to the Platform Admin. +The **InferencePool** API defines a group of Pods (containers) dedicated to serving AI models. Pods within an InferencePool share the same compute configuration, accelerator type, base language model, and model server. This abstraction simplifies the management of AI model serving resources, providing a centralized point of administrative configuration for Platform Admins. -It is expected for the InferencePool to: +An InferencePool is expected to be bundled with an [Endpoint Picker](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp) extension. This extension is responsible for tracking key metrics on each model server (i.e. the KV-cache utilization, queue length of pending requests, active LoRA adapters, etc.) and routing incoming inference requests to the optimal model server replica based on these metrics. An EPP can only be associated with a single InferencePool. The associated InferencePool is specified by the [poolName](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/config/manifests/inferencepool-resources.yaml#L54) and [poolNamespace](https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/config/manifests/inferencepool-resources.yaml#L56) flags. An HTTPRoute can have multiple backendRefs that reference the same InferencePool and therefore routes to the same EPP. An HTTPRoute can have multiple backendRefs that reference different InferencePools and therefore routes to different EPPs. - - Enforce fair consumption of resources across competing workloads - - Efficiently route requests across shared compute (as displayed by the PoC) - -It is _not_ expected for the InferencePool to: +Additionally, any Pod that seeks to join an InferencePool would need to support the [model server protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol), defined by this project, to ensure the Endpoint Picker has adequate information to intelligently route requests. - - Enforce any common set of adapters or base models are available on the Pods - - Manage Deployments of Pods within the Pool - - Manage Pod lifecycle of pods within the pool +## How to Configure an InferencePool -Additionally, any Pod that seeks to join an InferencePool would need to support a protocol, defined by this project, to ensure the Pool has adequate information to intelligently route requests. +The full spec of the InferencePool is defined [here](/reference/spec/#inferencepool). -`InferencePool` has some small overlap with `Service`, displayed here: +In summary, the InferencePoolSpec consists of 3 major parts: + +- The `selector` field specifies which Pods belong to this pool. The labels in this selector must exactly match the labels applied to your model server Pods. +- The `targetPortNumber` field defines the port number that the Inference Gateway should route to on model server Pods that belong to this pool. +- The `extensionRef` field references the [endpoint picker extension](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/pkg/epp) (EPP) service that monitors key metrics from model servers within the InferencePool and provides intelligent routing decisions. + +### Example Configuration + +Here is an example InferencePool configuration: + +``` +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + name: vllm-llama3-8b-instruct +spec: + targetPortNumber: 8000 + selector: + app: vllm-llama3-8b-instruct + extensionRef: + name: vllm-llama3-8b-instruct-epp + port: 9002 + failureMode: FailClose +``` + +In this example: + +- An InferencePool named `vllm-llama3-8b-instruct` is created in the `default` namespace. +- It will select Pods that have the label `app: vllm-llama3-8b-instruct`. +- Traffic routed to this InferencePool will call out to the EPP service `vllm-llama3-8b-instruct-epp` on port `9002` for making routing decisions. If EPP fails to pick an endpoint, or is not responsive, the request will be dropped. +- Traffic routed to this InferencePool will be forwarded to the port `8000` on the selected Pods. + +## Overlap with Service + +**InferencePool** has some small overlap with **Service**, displayed here: Comparing InferencePool with Service -The InferencePool is _not_ intended to be a mask of the Service object, simply exposing the absolute bare minimum required to allow the Platform Admin to focus less on networking, and more on Pool management. - -## Spec +The InferencePool is not intended to be a mask of the Service object. It provides a specialized abstraction tailored for managing and routing traffic to groups of LLM model servers, allowing Platform Admins to focus on pool-level management rather than low-level networking details. -The full spec of the InferencePool is defined [here](/reference/spec/#inferencepool). \ No newline at end of file +## Replacing an InferencePool +Please refer to the [Replacing an InferencePool](/guides/replacing-inference-pool) guide for details on uses cases and how to replace an InferencePool. diff --git a/site-src/guides/replacing-inference-pool.md b/site-src/guides/replacing-inference-pool.md new file mode 100644 index 00000000..21294570 --- /dev/null +++ b/site-src/guides/replacing-inference-pool.md @@ -0,0 +1,59 @@ +# Replacing an InferencePool + +## Background + +Replacing an InferencePool is a powerful technique for performing various infrastructure and model updates with minimal disruption and built-in rollback capabilities. This method allows you to introduce changes incrementally, monitor their impact, and revert to the previous state if necessary. + +## Use Cases +Use Cases for Replacing an InferencePool: + +- Upgrading or replacing your model server framework +- Upgrading or replacing your base model +- Transitioning to new hardware + +## How to replace an InferencePool + +To replacing an InferencePool: + +1. **Deploy new infrastructure**: Create a new InferencePool configured with the new hardware / model server / base model that you chose. +1. **Configure traffic splitting**: Use an HTTPRoute to split traffic between the existing InferencePool and the new InferencePool. The `backendRefs.weight` field controls the traffic percentage allocated to each pool. +1. **Maintain InferenceModel integrity**: Keep your InferenceModel configuration unchanged. This ensures that the system applies the same LoRA adapters consistently across both base model versions. +1. **Preserve rollback capability**: Retain the original nodes and InferencePool during the roll out to facilitate a rollback if necessary. + +### Example + +You start with an existing lnferencePool named `llm-pool-v1`. To replace the original InferencePool, you create a new InferencePool named `llm-pool-v2`. By configuring an **HTTPRoute**, as shown below, you can incrementally split traffic between the original `llm-pool-v1` and new `llm-pool-v2`. + +1. Save the following sample manifest as `httproute.yaml`: + + ```yaml + apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: llm-route + spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: inference-gateway + rules: + backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: llm-pool-v1 + weight: 90 + - group: inference.networking.x-k8s.io + kind: InferencePool + name: llm-pool-v2 + weight: 10 + ``` + +1. Apply the sample manifest to your cluster: + + ``` + kubectl apply -f httproute.yaml + ``` + + The original `llm-pool-v1` InferencePool receives most of the traffic, while the `llm-pool-v2` InferencePool receives the rest. + +1. Increase the traffic weight gradually for the `llm-pool-v2` InferencePool to complete the new InferencePool roll out. diff --git a/site-src/reference/spec.md b/site-src/reference/spec.md index e16c113c..d8e0c95b 100644 --- a/site-src/reference/spec.md +++ b/site-src/reference/spec.md @@ -1,12 +1,14 @@ # API Reference ## Packages -- [inference.networking.x-k8s.io/v1alpha1](#inferencenetworkingx-k8siov1alpha1) +- [inference.networking.x-k8s.io/v1alpha2](#inferencenetworkingx-k8siov1alpha2) -## inference.networking.x-k8s.io/v1alpha1 +## inference.networking.x-k8s.io/v1alpha2 + +Package v1alpha2 contains API Schema definitions for the +inference.networking.x-k8s.io API group. -Package v1alpha1 contains API Schema definitions for the gateway v1alpha1 API group ### Resource Types - [InferenceModel](#inferencemodel) @@ -18,26 +20,152 @@ Package v1alpha1 contains API Schema definitions for the gateway v1alpha1 API gr _Underlying type:_ _string_ -Defines how important it is to serve the model compared to other models. +Criticality defines how important it is to serve the model compared to other models. +Criticality is intentionally a bounded enum to contain the possibilities that need to be supported by the load balancing algorithm. Any reference to the Criticality field must be optional(use a pointer), and set no default. +This allows us to union this with a oneOf field in the future should we wish to adjust/extend this behavior. _Validation:_ -- Enum: [Critical Default Sheddable] +- Enum: [Critical Standard Sheddable] _Appears in:_ - [InferenceModelSpec](#inferencemodelspec) | Field | Description | | --- | --- | -| `Critical` | Most important. Requests to this band will be shed last.
| -| `Default` | More important than Sheddable, less important than Critical.
Requests in this band will be shed before critical traffic.
+kubebuilder:default=Default
| -| `Sheddable` | Least important. Requests to this band will be shed before all other bands.
| +| `Critical` | Critical defines the highest level of criticality. Requests to this band will be shed last.
| +| `Standard` | Standard defines the base criticality level and is more important than Sheddable but less
important than Critical. Requests in this band will be shed before critical traffic.
Most models are expected to fall within this band.
| +| `Sheddable` | Sheddable defines the lowest level of criticality. Requests to this band will be shed before
all other bands.
| + + +#### EndpointPickerConfig + + + +EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint picker extension. +This type is intended to be a union of mutually exclusive configuration options that we may add in the future. + + + +_Appears in:_ +- [InferencePoolSpec](#inferencepoolspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `extensionRef` _[Extension](#extension)_ | Extension configures an endpoint picker as an extension service. | | Required: \{\}
| + + +#### Extension + + + +Extension specifies how to configure an extension that runs the endpoint picker. + + + +_Appears in:_ +- [EndpointPickerConfig](#endpointpickerconfig) +- [InferencePoolSpec](#inferencepoolspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `group` _[Group](#group)_ | Group is the group of the referent.
The default value is "", representing the Core API group. | | MaxLength: 253
Pattern: `^$\|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
| +| `kind` _[Kind](#kind)_ | Kind is the Kubernetes resource kind of the referent. For example
"Service".
Defaults to "Service" when not specified.
ExternalName services can refer to CNAME DNS records that may live
outside of the cluster and as such are difficult to reason about in
terms of conformance. They also may not be safe to forward to (see
CVE-2021-25740 for more information). Implementations MUST NOT
support ExternalName Services. | Service | MaxLength: 63
MinLength: 1
Pattern: `^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$`
| +| `name` _[ObjectName](#objectname)_ | Name is the name of the referent. | | MaxLength: 253
MinLength: 1
Required: \{\}
| +| `portNumber` _[PortNumber](#portnumber)_ | The port number on the service running the extension. When unspecified,
implementations SHOULD infer a default value of 9002 when the Kind is
Service. | | Maximum: 65535
Minimum: 1
| +| `failureMode` _[ExtensionFailureMode](#extensionfailuremode)_ | Configures how the gateway handles the case when the extension is not responsive.
Defaults to failClose. | FailClose | Enum: [FailOpen FailClose]
| + + +#### ExtensionConnection + + + +ExtensionConnection encapsulates options that configures the connection to the extension. + + + +_Appears in:_ +- [Extension](#extension) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `failureMode` _[ExtensionFailureMode](#extensionfailuremode)_ | Configures how the gateway handles the case when the extension is not responsive.
Defaults to failClose. | FailClose | Enum: [FailOpen FailClose]
| + + +#### ExtensionFailureMode + +_Underlying type:_ _string_ + +ExtensionFailureMode defines the options for how the gateway handles the case when the extension is not +responsive. + +_Validation:_ +- Enum: [FailOpen FailClose] + +_Appears in:_ +- [Extension](#extension) +- [ExtensionConnection](#extensionconnection) + +| Field | Description | +| --- | --- | +| `FailOpen` | FailOpen specifies that the proxy should not drop the request and forward the request to and endpoint of its picking.
| +| `FailClose` | FailClose specifies that the proxy should drop the request.
| + + +#### ExtensionReference + + + +ExtensionReference is a reference to the extension deployment. + + + +_Appears in:_ +- [Extension](#extension) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `group` _[Group](#group)_ | Group is the group of the referent.
The default value is "", representing the Core API group. | | MaxLength: 253
Pattern: `^$\|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
| +| `kind` _[Kind](#kind)_ | Kind is the Kubernetes resource kind of the referent. For example
"Service".
Defaults to "Service" when not specified.
ExternalName services can refer to CNAME DNS records that may live
outside of the cluster and as such are difficult to reason about in
terms of conformance. They also may not be safe to forward to (see
CVE-2021-25740 for more information). Implementations MUST NOT
support ExternalName Services. | Service | MaxLength: 63
MinLength: 1
Pattern: `^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$`
| +| `name` _[ObjectName](#objectname)_ | Name is the name of the referent. | | MaxLength: 253
MinLength: 1
Required: \{\}
| +| `portNumber` _[PortNumber](#portnumber)_ | The port number on the service running the extension. When unspecified,
implementations SHOULD infer a default value of 9002 when the Kind is
Service. | | Maximum: 65535
Minimum: 1
| + + +#### Group + +_Underlying type:_ _string_ + +Group refers to a Kubernetes Group. It must either be an empty string or a +RFC 1123 subdomain. + +This validation is based off of the corresponding Kubernetes validation: +https://github.com/kubernetes/apimachinery/blob/02cfb53916346d085a6c6c7c66f882e3c6b0eca6/pkg/util/validation/validation.go#L208 + +Valid values include: + +* "" - empty string implies core Kubernetes API group +* "gateway.networking.k8s.io" +* "foo.example.com" + +Invalid values include: + +* "example.com/bar" - "/" is an invalid character + +_Validation:_ +- MaxLength: 253 +- Pattern: `^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + +_Appears in:_ +- [Extension](#extension) +- [ExtensionReference](#extensionreference) +- [PoolObjectReference](#poolobjectreference) + #### InferenceModel -InferenceModel is the Schema for the InferenceModels API +InferenceModel is the Schema for the InferenceModels API. @@ -45,29 +173,31 @@ InferenceModel is the Schema for the InferenceModels API | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `inference.networking.x-k8s.io/v1alpha1` | | | +| `apiVersion` _string_ | `inference.networking.x-k8s.io/v1alpha2` | | | | `kind` _string_ | `InferenceModel` | | | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | | `spec` _[InferenceModelSpec](#inferencemodelspec)_ | | | | | `status` _[InferenceModelStatus](#inferencemodelstatus)_ | | | | + + + + #### InferenceModelSpec -InferenceModelSpec represents a specific model use case. This resource is +InferenceModelSpec represents the desired state of a specific model use case. This resource is managed by the "Inference Workload Owner" persona. - -The Inference Workload Owner persona is: a team that trains, verifies, and +The Inference Workload Owner persona is someone that trains, verifies, and leverages a large language model from a model frontend, drives the lifecycle and rollout of new versions of those models, and defines the specific performance and latency goals for the model. These workloads are expected to operate within an InferencePool sharing compute capacity with other InferenceModels, defined by the Inference Platform Admin. - InferenceModel's modelName (not the ObjectMeta name) is unique for a given InferencePool, if the name is reused, an error will be shown on the status of a InferenceModel that attempted to reuse. The oldest InferenceModel, based on @@ -81,10 +211,10 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `modelName` _string_ | The name of the model as the users set in the "model" parameter in the requests.
The name should be unique among the workloads that reference the same backend pool.
This is the parameter that will be used to match the request with. In the future, we may
allow to match on other request parameters. The other approach to support matching on
on other request parameters is to use a different ModelName per HTTPFilter.
Names can be reserved without implementing an actual model in the pool.
This can be done by specifying a target model and setting the weight to zero,
an error will be returned specifying that no valid target model is found. | | MaxLength: 253
| -| `criticality` _[Criticality](#criticality)_ | Defines how important it is to serve the model compared to other models referencing the same pool. | Default | Enum: [Critical Default Sheddable]
| -| `targetModels` _[TargetModel](#targetmodel) array_ | Allow multiple versions of a model for traffic splitting.
If not specified, the target model name is defaulted to the modelName parameter.
modelName is often in reference to a LoRA adapter. | | MaxItems: 10
| -| `poolRef` _[PoolObjectReference](#poolobjectreference)_ | Reference to the inference pool, the pool must exist in the same namespace. | | Required: \{\}
| +| `modelName` _string_ | ModelName is the name of the model as it will be set in the "model" parameter for an incoming request.
ModelNames must be unique for a referencing InferencePool
(names can be reused for a different pool in the same cluster).
The modelName with the oldest creation timestamp is retained, and the incoming
InferenceModel is sets the Ready status to false with a corresponding reason.
In the rare case of a race condition, one Model will be selected randomly to be considered valid, and the other rejected.
Names can be reserved without an underlying model configured in the pool.
This can be done by specifying a target model and setting the weight to zero,
an error will be returned specifying that no valid target model is found. | | MaxLength: 256
Required: \{\}
| +| `criticality` _[Criticality](#criticality)_ | Criticality defines how important it is to serve the model compared to other models referencing the same pool.
Criticality impacts how traffic is handled in resource constrained situations. It handles this by
queuing or rejecting requests of lower criticality. InferenceModels of an equivalent Criticality will
fairly share resources over throughput of tokens. In the future, the metric used to calculate fairness,
and the proportionality of fairness will be configurable.
Default values for this field will not be set, to allow for future additions of new field that may 'one of' with this field.
Any implementations that may consume this field may treat an unset value as the 'Standard' range. | | Enum: [Critical Standard Sheddable]
| +| `targetModels` _[TargetModel](#targetmodel) array_ | TargetModels allow multiple versions of a model for traffic splitting.
If not specified, the target model name is defaulted to the modelName parameter.
modelName is often in reference to a LoRA adapter. | | MaxItems: 10
| +| `poolRef` _[PoolObjectReference](#poolobjectreference)_ | PoolRef is a reference to the inference pool, the pool must exist in the same namespace. | | Required: \{\}
| #### InferenceModelStatus @@ -100,14 +230,14 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions track the state of the InferencePool. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions track the state of the InferenceModel.
Known condition types are:
* "Accepted" | [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for controller reason:Pending status:Unknown type:Ready]] | MaxItems: 8
| #### InferencePool -InferencePool is the Schema for the Inferencepools API +InferencePool is the Schema for the InferencePools API. @@ -115,13 +245,17 @@ InferencePool is the Schema for the Inferencepools API | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `apiVersion` _string_ | `inference.networking.x-k8s.io/v1alpha1` | | | +| `apiVersion` _string_ | `inference.networking.x-k8s.io/v1alpha2` | | | | `kind` _string_ | `InferencePool` | | | | `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | | `spec` _[InferencePoolSpec](#inferencepoolspec)_ | | | | | `status` _[InferencePoolStatus](#inferencepoolstatus)_ | | | | + + + + #### InferencePoolSpec @@ -135,8 +269,9 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `selector` _object (keys:[LabelKey](#labelkey), values:[LabelValue](#labelvalue))_ | Selector uses a map of label to watch model server pods
that should be included in the InferencePool. ModelServers should not
be with any other Service or InferencePool, that behavior is not supported
and will result in sub-optimal utilization.
In some cases, implementations may translate this to a Service selector, so this matches the simple
map used for Service selectors instead of the full Kubernetes LabelSelector type. | | Required: \{\}
| -| `targetPortNumber` _integer_ | TargetPortNumber is the port number that the model servers within the pool expect
to receive traffic from.
This maps to the TargetPort in: https://pkg.go.dev/k8s.io/api/core/v1#ServicePort | | Maximum: 65535
Minimum: 0
Required: \{\}
| +| `selector` _object (keys:[LabelKey](#labelkey), values:[LabelValue](#labelvalue))_ | Selector defines a map of labels to watch model server pods
that should be included in the InferencePool.
In some cases, implementations may translate this field to a Service selector, so this matches the simple
map used for Service selectors instead of the full Kubernetes LabelSelector type.
If sepecified, it will be applied to match the model server pods in the same namespace as the InferencePool.
Cross namesoace selector is not supported. | | Required: \{\}
| +| `targetPortNumber` _integer_ | TargetPortNumber defines the port number to access the selected model servers.
The number must be in the range 1 to 65535. | | Maximum: 65535
Minimum: 1
Required: \{\}
| +| `extensionRef` _[Extension](#extension)_ | Extension configures an endpoint picker as an extension service. | | Required: \{\}
| #### InferencePoolStatus @@ -152,33 +287,56 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions track the state of the InferencePool. | | | +| `parent` _[PoolStatus](#poolstatus) array_ | Parents is a list of parent resources (usually Gateways) that are
associated with the route, and the status of the InferencePool with respect to
each parent.
A maximum of 32 Gateways will be represented in this list. An empty list
means the route has not been attached to any Gateway. | | MaxItems: 32
| + + +#### Kind + +_Underlying type:_ _string_ + +Kind refers to a Kubernetes Kind. + +Valid values include: + +* "Service" +* "HTTPRoute" + +Invalid values include: + +* "invalid/kind" - "/" is an invalid character + +_Validation:_ +- MaxLength: 63 +- MinLength: 1 +- Pattern: `^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$` + +_Appears in:_ +- [Extension](#extension) +- [ExtensionReference](#extensionreference) +- [PoolObjectReference](#poolobjectreference) + #### LabelKey _Underlying type:_ _string_ -Originally copied from: https://github.com/kubernetes-sigs/gateway-api/blob/99a3934c6bc1ce0874f3a4c5f20cafd8977ffcb4/apis/v1/shared_types.go#L694-L731 +LabelKey was originally copied from: https://github.com/kubernetes-sigs/gateway-api/blob/99a3934c6bc1ce0874f3a4c5f20cafd8977ffcb4/apis/v1/shared_types.go#L694-L731 Duplicated as to not take an unexpected dependency on gw's API. - LabelKey is the key of a label. This is used for validation of maps. This matches the Kubernetes "qualified name" validation that is used for labels. - +Labels are case sensitive, so: my-label and My-Label are considered distinct. Valid values include: - * example * example.com * example.com/path * example.com/path.html - Invalid values include: - * example~ - "~" is an invalid character * example.com. - can not start or end with "." @@ -202,10 +360,8 @@ of maps. This matches the Kubernetes label validation rules: * unless empty, must begin and end with an alphanumeric character ([a-z0-9A-Z]), * could contain dashes (-), underscores (_), dots (.), and alphanumerics between. - Valid values include: - * MyValue * my.name * 123-my-value @@ -220,6 +376,25 @@ _Appears in:_ +#### ObjectName + +_Underlying type:_ _string_ + +ObjectName refers to the name of a Kubernetes object. +Object names can have a variety of forms, including RFC 1123 subdomains, +RFC 1123 labels, or RFC 1035 labels. + +_Validation:_ +- MaxLength: 253 +- MinLength: 1 + +_Appears in:_ +- [Extension](#extension) +- [ExtensionReference](#extensionreference) +- [PoolObjectReference](#poolobjectreference) + + + #### PoolObjectReference @@ -234,9 +409,42 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `group` _string_ | Group is the group of the referent. | inference.networking.x-k8s.io | MaxLength: 253
Pattern: `^$\|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
| -| `kind` _string_ | Kind is kind of the referent. For example "InferencePool". | InferencePool | MaxLength: 63
MinLength: 1
Pattern: `^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$`
| -| `name` _string_ | Name is the name of the referent. | | MaxLength: 253
MinLength: 1
Required: \{\}
| +| `group` _[Group](#group)_ | Group is the group of the referent. | inference.networking.x-k8s.io | MaxLength: 253
Pattern: `^$\|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
| +| `kind` _[Kind](#kind)_ | Kind is kind of the referent. For example "InferencePool". | InferencePool | MaxLength: 63
MinLength: 1
Pattern: `^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$`
| +| `name` _[ObjectName](#objectname)_ | Name is the name of the referent. | | MaxLength: 253
MinLength: 1
Required: \{\}
| + + +#### PoolStatus + + + +PoolStatus defines the observed state of InferencePool from a Gateway. + + + +_Appears in:_ +- [InferencePoolStatus](#inferencepoolstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `parentRef` _[ObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#objectreference-v1-core)_ | GatewayRef indicates the gateway that observed state of InferencePool. | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | Conditions track the state of the InferencePool.
Known condition types are:
* "Accepted"
* "ResolvedRefs" | [map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for controller reason:Pending status:Unknown type:Accepted]] | MaxItems: 8
| + + +#### PortNumber + +_Underlying type:_ _integer_ + +PortNumber defines a network port. + +_Validation:_ +- Maximum: 65535 +- Minimum: 1 + +_Appears in:_ +- [Extension](#extension) +- [ExtensionReference](#extensionreference) + #### TargetModel @@ -246,10 +454,10 @@ _Appears in:_ TargetModel represents a deployed model or a LoRA adapter. The Name field is expected to match the name of the LoRA adapter (or base model) as it is registered within the model server. Inference -Gateway assumes that the model exists on the model server and is the +Gateway assumes that the model exists on the model server and it's the responsibility of the user to validate a correct match. Should a model fail -to exist at request time, the error is processed by the Instance Gateway, -and then emitted on the appropriate InferenceModel object. +to exist at request time, the error is processed by the Inference Gateway +and emitted on the appropriate InferenceModel object. @@ -258,7 +466,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `name` _string_ | The name of the adapter as expected by the ModelServer. | | MaxLength: 253
| -| `weight` _integer_ | Weight is used to determine the proportion of traffic that should be
sent to this target model when multiple versions of the model are specified. | 1 | Maximum: 1e+06
Minimum: 0
| +| `name` _string_ | Name is the name of the adapter or base model, as expected by the ModelServer. | | MaxLength: 253
Required: \{\}
| +| `weight` _integer_ | Weight is used to determine the proportion of traffic that should be
sent to this model when multiple target models are specified.
Weight defines the proportion of requests forwarded to the specified
model. This is computed as weight/(sum of all weights in this
TargetModels list). For non-zero values, there may be some epsilon from
the exact proportion defined here depending on the precision an
implementation supports. Weight is not a percentage and the sum of
weights does not need to equal 100.
If a weight is set for any targetModel, it must be set for all targetModels.
Conversely weights are optional, so long as ALL targetModels do not specify a weight. | | Maximum: 1e+06
Minimum: 1
| From f1d425b7e5d460dd8c64bcf30c0466c079951af3 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 23 Apr 2025 04:27:40 +0300 Subject: [PATCH 233/260] reduce log level in metrics logger not to trash the log (#708) * reduce log level in metrics logger not to trash the log Signed-off-by: Nir Rozenbaum * rename flush metrics to refresh metrics Signed-off-by: Nir Rozenbaum * revert log level Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- cmd/epp/main.go | 9 ++++----- pkg/epp/backend/metrics/logger.go | 10 +++++----- pkg/epp/server/runserver.go | 17 ++++++----------- test/integration/epp/hermetic_test.go | 2 +- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index b5e6fbe6..c0a87e62 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -142,8 +142,8 @@ func run() error { } poolNamespacedName := types.NamespacedName{ - Namespace: *poolNamespace, Name: *poolName, + Namespace: *poolNamespace, } mgr, err := runserver.NewDefaultManager(poolNamespacedName, cfg) if err != nil { @@ -151,8 +151,6 @@ func run() error { return err } - ctx := ctrl.SetupSignalHandler() - // Set up mapper for metric scraping. mapping, err := backendmetrics.NewMetricMapping( *totalQueuedRequestsMetric, @@ -167,14 +165,15 @@ func run() error { pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.PodMetricsClientImpl{MetricMapping: mapping}, *refreshMetricsInterval) // Setup runner. + ctx := ctrl.SetupSignalHandler() + datastore := datastore.NewDatastore(ctx, pmf) serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, DestinationEndpointHintKey: *destinationEndpointHintKey, - PoolName: *poolName, - PoolNamespace: *poolNamespace, + PoolNamespacedName: poolNamespacedName, Datastore: datastore, SecureServing: *secureServing, CertPath: *certPath, diff --git a/pkg/epp/backend/metrics/logger.go b/pkg/epp/backend/metrics/logger.go index d9a93027..7dc1a8b8 100644 --- a/pkg/epp/backend/metrics/logger.go +++ b/pkg/epp/backend/metrics/logger.go @@ -55,8 +55,8 @@ func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometh case <-ctx.Done(): logger.V(logutil.DEFAULT).Info("Shutting down prometheus metrics thread") return - case <-ticker.C: // Periodically flush prometheus metrics for inference pool - flushPrometheusMetricsOnce(logger, datastore) + case <-ticker.C: // Periodically refresh prometheus metrics for inference pool + refreshPrometheusMetrics(logger, datastore) } } }() @@ -86,11 +86,11 @@ func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometh } } -func flushPrometheusMetricsOnce(logger logr.Logger, datastore Datastore) { +func refreshPrometheusMetrics(logger logr.Logger, datastore Datastore) { pool, err := datastore.PoolGet() if err != nil { // No inference pool or not initialize. - logger.V(logutil.DEFAULT).Info("pool is not initialized, skipping flushing metrics") + logger.V(logutil.DEFAULT).Info("Pool is not initialized, skipping refreshing metrics") return } @@ -98,7 +98,7 @@ func flushPrometheusMetricsOnce(logger logr.Logger, datastore Datastore) { var queueTotal int podMetrics := datastore.PodGetAll() - logger.V(logutil.VERBOSE).Info("Flushing Prometheus Metrics", "ReadyPods", len(podMetrics)) + logger.V(logutil.TRACE).Info("Refreshing Prometheus Metrics", "ReadyPods", len(podMetrics)) if len(podMetrics) == 0 { return } diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 65a6e787..0c0a6a6d 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -43,8 +43,7 @@ type ExtProcServerRunner struct { GrpcPort int DestinationEndpointHintMetadataNamespace string DestinationEndpointHintKey string - PoolName string - PoolNamespace string + PoolNamespacedName types.NamespacedName Datastore datastore.Datastore SecureServing bool CertPath string @@ -73,8 +72,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { GrpcPort: DefaultGrpcPort, DestinationEndpointHintKey: DefaultDestinationEndpointHintKey, DestinationEndpointHintMetadataNamespace: DefaultDestinationEndpointHintMetadataNamespace, - PoolName: DefaultPoolName, - PoolNamespace: DefaultPoolNamespace, + PoolNamespacedName: types.NamespacedName{Name: DefaultPoolName, Namespace: DefaultPoolNamespace}, SecureServing: DefaultSecureServing, RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, // Datastore can be assigned later. @@ -93,13 +91,10 @@ func (r *ExtProcServerRunner) SetupWithManager(ctx context.Context, mgr ctrl.Man } if err := (&controller.InferenceModelReconciler{ - Datastore: r.Datastore, - Client: mgr.GetClient(), - PoolNamespacedName: types.NamespacedName{ - Name: r.PoolName, - Namespace: r.PoolNamespace, - }, - Record: mgr.GetEventRecorderFor("InferenceModel"), + Datastore: r.Datastore, + Client: mgr.GetClient(), + PoolNamespacedName: r.PoolNamespacedName, + Record: mgr.GetEventRecorderFor("InferenceModel"), }).SetupWithManager(ctx, mgr); err != nil { return fmt.Errorf("failed setting up InferenceModelReconciler: %w", err) } diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 372158f4..79b619fd 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -1348,7 +1348,7 @@ func BeforeSuite() func() { serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) // Adjust from defaults - serverRunner.PoolName = "vllm-llama3-8b-instruct-pool" + serverRunner.PoolNamespacedName = types.NamespacedName{Name: "vllm-llama3-8b-instruct-pool", Namespace: "default"} serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) serverRunner.SecureServing = false From d935a7cc9bec473d04f10147f2f012e33757da98 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 23 Apr 2025 04:27:47 +0300 Subject: [PATCH 234/260] few updates in datastore (#713) * few updates in datastore Signed-off-by: Nir Rozenbaum * PoolSet documentation Signed-off-by: Nir Rozenbaum * error phrasing Signed-off-by: Nir Rozenbaum * removed unused pool arg from PodUpdateOrAddIfNotExist Signed-off-by: Nir Rozenbaum * linter Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- .../inferencemodel_reconciler_test.go | 5 +- .../controller/inferencepool_reconciler.go | 24 ++---- pkg/epp/controller/pod_reconciler.go | 12 ++- pkg/epp/controller/pod_reconciler_test.go | 4 +- pkg/epp/datastore/datastore.go | 78 ++++++++++++------- pkg/epp/datastore/datastore_test.go | 21 ++++- pkg/epp/util/pod/pod.go | 3 + 7 files changed, 89 insertions(+), 58 deletions(-) diff --git a/pkg/epp/controller/inferencemodel_reconciler_test.go b/pkg/epp/controller/inferencemodel_reconciler_test.go index 57dc2469..80c30e19 100644 --- a/pkg/epp/controller/inferencemodel_reconciler_test.go +++ b/pkg/epp/controller/inferencemodel_reconciler_test.go @@ -25,6 +25,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -178,6 +179,7 @@ func TestInferenceModelReconciler(t *testing.T) { t.Run(test.name, func(t *testing.T) { // Create a fake client with no InferenceModel objects. scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) _ = v1alpha2.Install(scheme) initObjs := []client.Object{} if test.model != nil { @@ -186,6 +188,7 @@ func TestInferenceModelReconciler(t *testing.T) { for _, m := range test.modelsInAPIServer { initObjs = append(initObjs, m) } + fakeClient := fake.NewClientBuilder(). WithScheme(scheme). WithObjects(initObjs...). @@ -196,7 +199,7 @@ func TestInferenceModelReconciler(t *testing.T) { for _, m := range test.modelsInStore { ds.ModelSetIfOlder(m) } - ds.PoolSet(pool) + _ = ds.PoolSet(context.Background(), fakeClient, pool) reconciler := &InferenceModelReconciler{ Client: fakeClient, Record: record.NewFakeRecorder(10), diff --git a/pkg/epp/controller/inferencepool_reconciler.go b/pkg/epp/controller/inferencepool_reconciler.go index 0738181f..fb7d7727 100644 --- a/pkg/epp/controller/inferencepool_reconciler.go +++ b/pkg/epp/controller/inferencepool_reconciler.go @@ -18,7 +18,6 @@ package controller import ( "context" - "reflect" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/client-go/tools/record" @@ -60,28 +59,15 @@ func (c *InferencePoolReconciler) Reconcile(ctx context.Context, req ctrl.Reques c.Datastore.Clear() return ctrl.Result{}, nil } - - c.updateDatastore(ctx, infPool) + // update pool in datastore + if err := c.Datastore.PoolSet(ctx, c.Client, infPool); err != nil { + logger.Error(err, "Failed to update datastore") + return ctrl.Result{}, err + } return ctrl.Result{}, nil } -func (c *InferencePoolReconciler) updateDatastore(ctx context.Context, newPool *v1alpha2.InferencePool) { - logger := log.FromContext(ctx) - oldPool, err := c.Datastore.PoolGet() - c.Datastore.PoolSet(newPool) - if err != nil || !reflect.DeepEqual(newPool.Spec.Selector, oldPool.Spec.Selector) { - logger.V(logutil.DEFAULT).Info("Updating inference pool endpoints", "selector", newPool.Spec.Selector) - // A full resync is required to address two cases: - // 1) At startup, the pod events may get processed before the pool is synced with the datastore, - // and hence they will not be added to the store since pool selector is not known yet - // 2) If the selector on the pool was updated, then we will not get any pod events, and so we need - // to resync the whole pool: remove pods in the store that don't match the new selector and add - // the ones that may have existed already to the store. - c.Datastore.PodResyncAll(ctx, c.Client, newPool) - } -} - func (c *InferencePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha2.InferencePool{}). diff --git a/pkg/epp/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go index 494adeb7..6d1af8d9 100644 --- a/pkg/epp/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -27,7 +27,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" podutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/pod" @@ -41,8 +40,7 @@ type PodReconciler struct { func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := log.FromContext(ctx) - pool, err := c.Datastore.PoolGet() - if err != nil { + if !c.Datastore.PoolHasSynced() { logger.V(logutil.TRACE).Info("Skipping reconciling Pod because the InferencePool is not available yet") // When the inferencePool is initialized it lists the appropriate pods and populates the datastore, so no need to requeue. return ctrl.Result{}, nil @@ -60,7 +58,7 @@ func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R return ctrl.Result{}, err } - c.updateDatastore(logger, pod, pool) + c.updateDatastore(logger, pod) return ctrl.Result{}, nil } @@ -70,13 +68,13 @@ func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(c) } -func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod, pool *v1alpha2.InferencePool) { +func (c *PodReconciler) updateDatastore(logger logr.Logger, pod *corev1.Pod) { namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} - if !pod.DeletionTimestamp.IsZero() || !c.Datastore.PoolLabelsMatch(pod.Labels) || !podutil.IsPodReady(pod) { + if !podutil.IsPodReady(pod) || !c.Datastore.PoolLabelsMatch(pod.Labels) { logger.V(logutil.DEBUG).Info("Pod removed or not added", "name", namespacedName) c.Datastore.PodDelete(namespacedName) } else { - if c.Datastore.PodUpdateOrAddIfNotExist(pod, pool) { + if c.Datastore.PodUpdateOrAddIfNotExist(pod) { logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName) } else { logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName) diff --git a/pkg/epp/controller/pod_reconciler_test.go b/pkg/epp/controller/pod_reconciler_test.go index e4cb0b62..d2bdd5d0 100644 --- a/pkg/epp/controller/pod_reconciler_test.go +++ b/pkg/epp/controller/pod_reconciler_test.go @@ -182,9 +182,9 @@ func TestPodReconciler(t *testing.T) { // Configure the initial state of the datastore. store := datastore.NewDatastore(t.Context(), pmf) - store.PoolSet(test.pool) + _ = store.PoolSet(t.Context(), fakeClient, test.pool) for _, pod := range test.existingPods { - store.PodUpdateOrAddIfNotExist(pod, pool) + store.PodUpdateOrAddIfNotExist(pod) } podReconciler := &PodReconciler{Client: fakeClient, Datastore: store} diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index 5435e3af..f8378d25 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "reflect" "sync" corev1 "k8s.io/api/core/v1" @@ -44,7 +45,10 @@ var ( // The datastore is a local cache of relevant data for the given InferencePool (currently all pulled from k8s-api) type Datastore interface { // InferencePool operations - PoolSet(pool *v1alpha2.InferencePool) + // PoolSet sets the given pool in datastore. If the given pool has different label selector than the previous pool + // that was stored, the function triggers a resync of the pods to keep the datastore updated. If the given pool + // is nil, this call triggers the datastore.Clear() function. + PoolSet(ctx context.Context, client client.Client, pool *v1alpha2.InferencePool) error PoolGet() (*v1alpha2.InferencePool, error) PoolHasSynced() bool PoolLabelsMatch(podLabels map[string]string) bool @@ -60,10 +64,9 @@ type Datastore interface { // PodGetAll returns all pods and metrics, including fresh and stale. PodGetAll() []backendmetrics.PodMetrics // PodList lists pods matching the given predicate. - PodList(func(backendmetrics.PodMetrics) bool) []backendmetrics.PodMetrics - PodUpdateOrAddIfNotExist(pod *corev1.Pod, pool *v1alpha2.InferencePool) bool + PodList(predicate func(backendmetrics.PodMetrics) bool) []backendmetrics.PodMetrics + PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool PodDelete(namespacedName types.NamespacedName) - PodResyncAll(ctx context.Context, ctrlClient client.Client, pool *v1alpha2.InferencePool) // Clears the store state, happens when the pool gets deleted. Clear() @@ -102,10 +105,31 @@ func (ds *datastore) Clear() { } // /// InferencePool APIs /// -func (ds *datastore) PoolSet(pool *v1alpha2.InferencePool) { +func (ds *datastore) PoolSet(ctx context.Context, client client.Client, pool *v1alpha2.InferencePool) error { + if pool == nil { + ds.Clear() + return nil + } + logger := log.FromContext(ctx) ds.poolAndModelsMu.Lock() defer ds.poolAndModelsMu.Unlock() + + oldPool := ds.pool ds.pool = pool + if oldPool == nil || !reflect.DeepEqual(pool.Spec.Selector, oldPool.Spec.Selector) { + logger.V(logutil.DEFAULT).Info("Updating inference pool endpoints", "selector", pool.Spec.Selector) + // A full resync is required to address two cases: + // 1) At startup, the pod events may get processed before the pool is synced with the datastore, + // and hence they will not be added to the store since pool selector is not known yet + // 2) If the selector on the pool was updated, then we will not get any pod events, and so we need + // to resync the whole pool: remove pods in the store that don't match the new selector and add + // the ones that may have existed already to the store. + if err := ds.podResyncAll(ctx, client); err != nil { + return fmt.Errorf("failed to update pods according to the pool selector - %w", err) + } + } + + return nil } func (ds *datastore) PoolGet() (*v1alpha2.InferencePool, error) { @@ -229,7 +253,7 @@ func (ds *datastore) PodList(predicate func(backendmetrics.PodMetrics) bool) []b return res } -func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod, pool *v1alpha2.InferencePool) bool { +func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod) bool { namespacedName := types.NamespacedName{ Name: pod.Name, Namespace: pod.Namespace, @@ -247,27 +271,35 @@ func (ds *datastore) PodUpdateOrAddIfNotExist(pod *corev1.Pod, pool *v1alpha2.In return ok } -func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client, pool *v1alpha2.InferencePool) { +func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { + v, ok := ds.pods.LoadAndDelete(namespacedName) + if ok { + pmr := v.(backendmetrics.PodMetrics) + pmr.StopRefreshLoop() + } +} + +func (ds *datastore) podResyncAll(ctx context.Context, ctrlClient client.Client) error { logger := log.FromContext(ctx) podList := &corev1.PodList{} if err := ctrlClient.List(ctx, podList, &client.ListOptions{ - LabelSelector: selectorFromInferencePoolSelector(pool.Spec.Selector), - Namespace: pool.Namespace, + LabelSelector: selectorFromInferencePoolSelector(ds.pool.Spec.Selector), + Namespace: ds.pool.Namespace, }); err != nil { - log.FromContext(ctx).V(logutil.DEFAULT).Error(err, "Failed to list clients") - return + return fmt.Errorf("failed to list pods - %w", err) } activePods := make(map[string]bool) for _, pod := range podList.Items { - if podutil.IsPodReady(&pod) { - namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} - activePods[pod.Name] = true - if ds.PodUpdateOrAddIfNotExist(&pod, pool) { - logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName) - } else { - logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName) - } + if !podutil.IsPodReady(&pod) { + continue + } + namespacedName := types.NamespacedName{Name: pod.Name, Namespace: pod.Namespace} + activePods[pod.Name] = true + if ds.PodUpdateOrAddIfNotExist(&pod) { + logger.V(logutil.DEFAULT).Info("Pod added", "name", namespacedName) + } else { + logger.V(logutil.DEFAULT).Info("Pod already exists", "name", namespacedName) } } @@ -281,14 +313,8 @@ func (ds *datastore) PodResyncAll(ctx context.Context, ctrlClient client.Client, return true } ds.pods.Range(deleteFn) -} -func (ds *datastore) PodDelete(namespacedName types.NamespacedName) { - v, ok := ds.pods.LoadAndDelete(namespacedName) - if ok { - pmr := v.(backendmetrics.PodMetrics) - pmr.StopRefreshLoop() - } + return nil } func selectorFromInferencePoolSelector(selector map[v1alpha2.LabelKey]v1alpha2.LabelValue) labels.Selector { diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index abbff429..e8c77d37 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -27,7 +27,10 @@ import ( "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" testutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" @@ -71,9 +74,15 @@ func TestPool(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Set up the scheme. + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) datastore := NewDatastore(context.Background(), pmf) - datastore.PoolSet(tt.inferencePool) + _ = datastore.PoolSet(context.Background(), fakeClient, tt.inferencePool) gotPool, gotErr := datastore.PoolGet() if diff := cmp.Diff(tt.wantErr, gotErr, cmpopts.EquateErrors()); diff != "" { t.Errorf("Unexpected error diff (+got/-want): %s", diff) @@ -320,11 +329,17 @@ func TestMetrics(t *testing.T) { t.Run(test.name, func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Set up the scheme. + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() pmf := backendmetrics.NewPodMetricsFactory(test.pmc, time.Millisecond) ds := NewDatastore(ctx, pmf) - ds.PoolSet(inferencePool) + _ = ds.PoolSet(ctx, fakeClient, inferencePool) for _, pod := range test.storePods { - ds.PodUpdateOrAddIfNotExist(pod, inferencePool) + ds.PodUpdateOrAddIfNotExist(pod) } assert.EventuallyWithT(t, func(t *assert.CollectT) { got := ds.PodGetAll() diff --git a/pkg/epp/util/pod/pod.go b/pkg/epp/util/pod/pod.go index 9f564024..4fcb948f 100644 --- a/pkg/epp/util/pod/pod.go +++ b/pkg/epp/util/pod/pod.go @@ -21,6 +21,9 @@ import ( ) func IsPodReady(pod *corev1.Pod) bool { + if !pod.DeletionTimestamp.IsZero() { + return false + } for _, condition := range pod.Status.Conditions { if condition.Type == corev1.PodReady { if condition.Status == corev1.ConditionTrue { From b24f94834724df5af902d014f1f4d6ca177c89e6 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 23 Apr 2025 20:15:47 +0300 Subject: [PATCH 235/260] scheduler refactoring (#730) Signed-off-by: Nir Rozenbaum --- pkg/epp/backend/metrics/pod_metrics.go | 11 +- pkg/epp/backend/metrics/types.go | 15 +- .../scheduling/plugins/{ => filter}/filter.go | 85 +++++------ .../plugins/{ => filter}/filter_test.go | 38 ++--- pkg/epp/scheduling/plugins/noop.go | 12 +- .../{picker.go => picker/random_picker.go} | 6 +- .../interfaces.go => plugins/plugins.go} | 42 +++--- pkg/epp/scheduling/scheduler.go | 141 ++++++++---------- pkg/epp/scheduling/scheduler_test.go | 133 ++++++----------- pkg/epp/scheduling/types/types.go | 16 +- 10 files changed, 214 insertions(+), 285 deletions(-) rename pkg/epp/scheduling/plugins/{ => filter}/filter.go (81%) rename pkg/epp/scheduling/plugins/{ => filter}/filter_test.go (90%) rename pkg/epp/scheduling/plugins/{picker.go => picker/random_picker.go} (86%) rename pkg/epp/scheduling/{types/interfaces.go => plugins/plugins.go} (70%) diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index c85d4d79..7339389a 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -41,9 +41,8 @@ type podMetrics struct { ds Datastore interval time.Duration - parentCtx context.Context - once sync.Once // ensure the StartRefreshLoop is only called once. - done chan struct{} + once sync.Once // ensure the StartRefreshLoop is only called once. + done chan struct{} logger logr.Logger } @@ -79,8 +78,8 @@ func toInternalPod(in *corev1.Pod) *Pod { } // start starts a goroutine exactly once to periodically update metrics. The goroutine will be -// stopped either when stop() is called, or the parentCtx is cancelled. -func (pm *podMetrics) startRefreshLoop() { +// stopped either when stop() is called, or the given ctx is cancelled. +func (pm *podMetrics) startRefreshLoop(ctx context.Context) { pm.once.Do(func() { go func() { pm.logger.V(logutil.DEFAULT).Info("Starting refresher", "pod", pm.GetPod()) @@ -90,7 +89,7 @@ func (pm *podMetrics) startRefreshLoop() { select { case <-pm.done: return - case <-pm.parentCtx.Done(): + case <-ctx.Done(): return case <-ticker.C: // refresh metrics periodically if err := pm.refreshMetrics(); err != nil { diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index 21c0f401..156ac3ed 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -43,18 +43,17 @@ type PodMetricsFactory struct { func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1.Pod, ds Datastore) PodMetrics { pod := toInternalPod(in) pm := &podMetrics{ - pmc: f.pmc, - ds: ds, - interval: f.refreshMetricsInterval, - parentCtx: parentCtx, - once: sync.Once{}, - done: make(chan struct{}), - logger: log.FromContext(parentCtx).WithValues("pod", pod.NamespacedName), + pmc: f.pmc, + ds: ds, + interval: f.refreshMetricsInterval, + once: sync.Once{}, + done: make(chan struct{}), + logger: log.FromContext(parentCtx).WithValues("pod", pod.NamespacedName), } pm.pod.Store(pod) pm.metrics.Store(newMetrics()) - pm.startRefreshLoop() + pm.startRefreshLoop(parentCtx) return pm } diff --git a/pkg/epp/scheduling/plugins/filter.go b/pkg/epp/scheduling/plugins/filter/filter.go similarity index 81% rename from pkg/epp/scheduling/plugins/filter.go rename to pkg/epp/scheduling/plugins/filter/filter.go index efcb6be1..86620aa9 100644 --- a/pkg/epp/scheduling/plugins/filter.go +++ b/pkg/epp/scheduling/plugins/filter/filter.go @@ -14,56 +14,55 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package filter import ( - "errors" "math" "math/rand" "time" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -type Filter struct { +type baseFilter struct { name string filter filterFunc } -func (bf *Filter) Name() string { - if bf == nil { +func (f *baseFilter) Name() string { + if f == nil { return "nil" } - return bf.name + return f.name } -func (bf *Filter) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func (f *baseFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { loggerTrace := ctx.Logger.V(logutil.TRACE) - loggerTrace.Info("Running a filter", "name", bf.Name(), "podCount", len(pods)) + loggerTrace.Info("Running a filter", "name", f.Name(), "podCount", len(pods)) - return bf.filter(ctx, pods) + return f.filter(ctx, pods) } // DecisionTreeFilter applies current filterFunc, and then recursively applies next filters // depending success or failure of the current filter. // It can be used to construct a flow chart algorithm. type DecisionTreeFilter struct { - Current types.Filter + Current plugins.Filter // NextOnSuccess filter will be applied after successfully applying the current filter. // The filtered results will be passed to the next filter. - NextOnSuccess types.Filter + NextOnSuccess plugins.Filter // NextOnFailure filter will be applied if current filter fails. // The original input will be passed to the next filter. - NextOnFailure types.Filter + NextOnFailure plugins.Filter // NextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the // success or failure of the current filter. // NOTE: When using NextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. // However if that's not the case, nextOnSuccess and nextOnFailure will be used, instead of // NextOnSuccessOrFailure, in the success and failure scenarios, respectively. - NextOnSuccessOrFailure types.Filter + NextOnSuccessOrFailure plugins.Filter } func (f *DecisionTreeFilter) Name() string { @@ -73,15 +72,15 @@ func (f *DecisionTreeFilter) Name() string { return f.Current.Name() } -func (f *DecisionTreeFilter) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func (f *DecisionTreeFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { loggerTrace := ctx.Logger.V(logutil.TRACE) - filtered, err := f.Current.Filter(ctx, pods) + filtered := f.Current.Filter(ctx, pods) next := f.NextOnSuccessOrFailure - if err == nil && len(filtered) > 0 { + if len(filtered) > 0 { if f.NextOnSuccess == nil && f.NextOnSuccessOrFailure == nil { // No succeeding filters to run, return. - return filtered, err + return filtered } if f.NextOnSuccess != nil { next = f.NextOnSuccess @@ -92,7 +91,7 @@ func (f *DecisionTreeFilter) Filter(ctx *types.Context, pods []types.Pod) ([]typ } else { if f.NextOnFailure == nil && f.NextOnSuccessOrFailure == nil { // No succeeding filters to run, return. - return filtered, err + return filtered } if f.NextOnFailure != nil { next = f.NextOnFailure @@ -104,11 +103,11 @@ func (f *DecisionTreeFilter) Filter(ctx *types.Context, pods []types.Pod) ([]typ } // filterFunc filters a set of input pods to a subset. -type filterFunc func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) +type filterFunc func(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod // toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. func toFilterFunc(pp podPredicate) filterFunc { - return func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { + return func(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { filtered := []types.Pod{} for _, pod := range pods { pass := pp(ctx.Req, pod) @@ -116,14 +115,12 @@ func toFilterFunc(pp podPredicate) filterFunc { filtered = append(filtered, pod) } } - if len(filtered) == 0 { - return nil, errors.New("no pods left") - } - return filtered, nil + + return filtered } } -var LeastQueueFilter = &Filter{ +var LeastQueueFilter = &baseFilter{ name: "least queuing", filter: leastQueuingFilterFunc, } @@ -135,7 +132,7 @@ var LeastQueueFilter = &Filter{ // the least one as it gives more choices for the next filter, which on aggregate gave better // results. // TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func leastQueuingFilterFunc(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { min := math.MaxInt max := 0 filtered := []types.Pod{} @@ -154,15 +151,15 @@ func leastQueuingFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, filtered = append(filtered, pod) } } - return filtered, nil + return filtered } -var LowQueueFilter = &Filter{ +var LowQueueFilter = &baseFilter{ name: "low queueing filter", filter: toFilterFunc((queueThresholdPredicate(config.Conf.QueueingThresholdLoRA))), } -var LeastKVCacheFilter = &Filter{ +var LeastKVCacheFilter = &baseFilter{ name: "least KV cache percent", filter: leastKVCacheFilterFunc, } @@ -173,7 +170,7 @@ var LeastKVCacheFilter = &Filter{ // should consider them all instead of the absolute minimum one. This worked better than picking the // least one as it gives more choices for the next filter, which on aggregate gave better results. // TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func leastKVCacheFilterFunc(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { min := math.MaxFloat64 var max float64 = 0 filtered := []types.Pod{} @@ -192,10 +189,10 @@ func leastKVCacheFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, filtered = append(filtered, pod) } } - return filtered, nil + return filtered } -var LoRAAffinityFilter = &Filter{ +var LoRAAffinityFilter = &baseFilter{ name: "affinity LoRA", filter: loRASoftAffinityFilterFunc, } @@ -216,7 +213,7 @@ var LoRAAffinityFilter = &Filter{ // Returns: // - Filtered slice of pod metrics based on affinity and availability // - Error if any issues occur during filtering -func loRASoftAffinityFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func loRASoftAffinityFilterFunc(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { // Pre-allocate slices with estimated capacity filtered_affinity := make([]types.Pod, 0, len(pods)) @@ -241,34 +238,24 @@ func loRASoftAffinityFilterFunc(ctx *types.Context, pods []types.Pod) ([]types.P // If both groups have pods, use probability to select which group to return if len(filtered_affinity) > 0 && len(filtered_available) > 0 { if randGen.Float64() < config.Conf.LoraAffinityThreshold { - return filtered_affinity, nil + return filtered_affinity } - return filtered_available, nil + return filtered_available } // Return whichever group has pods if len(filtered_affinity) > 0 { - return filtered_affinity, nil + return filtered_affinity } - return filtered_available, nil + return filtered_available } -var HasCapacityFilter = &Filter{ +var HasCapacityFilter = &baseFilter{ name: "has capacity for sheddable requests", filter: toFilterFunc(queueThresholdPredicate(config.Conf.QueueThresholdCritical).and(kvCacheThresholdPredicate(config.Conf.KVCacheThreshold))), } -var DropRequestFilter = &Filter{ - name: "drop request", - filter: func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { - ctx.Logger.V(logutil.DEFAULT).Info("Request dropped", "request", ctx.Req) - return []types.Pod{}, errutil.Error{ - Code: errutil.InferencePoolResourceExhausted, Msg: "dropping request due to limited backend resources", - } - }, -} - // podPredicate is a filter function to check whether a pod is desired. type podPredicate func(req *types.LLMRequest, pod types.Pod) bool diff --git a/pkg/epp/scheduling/plugins/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go similarity index 90% rename from pkg/epp/scheduling/plugins/filter_test.go rename to pkg/epp/scheduling/plugins/filter/filter_test.go index 107b423f..56cccb3b 100644 --- a/pkg/epp/scheduling/plugins/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -14,11 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package filter import ( "context" - "errors" "testing" "github.com/google/go-cmp/cmp" @@ -34,30 +33,26 @@ func TestFilter(t *testing.T) { req *types.LLMRequest input []types.Pod output []types.Pod - err bool filter *DecisionTreeFilter }{ { - name: "simple filter without successor, failure", + name: "simple filter without available pods", filter: &DecisionTreeFilter{ - Current: &Filter{ - name: "error", - filter: func(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { - return nil, errors.New("filter error") + Current: &baseFilter{ + name: "filter all", + filter: func(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + return []types.Pod{} }, }, }, - err: true, + output: []types.Pod{}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ctx := types.NewContext(context.Background(), test.req, test.input) - got, err := test.filter.Filter(ctx, test.input) - if test.err != (err != nil) { - t.Errorf("Unexpected error, got %v, want %v", err, test.err) - } + ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) + got := test.filter.Filter(ctx, test.input) opt := cmp.AllowUnexported(types.PodMetrics{}) if diff := cmp.Diff(test.output, got, opt); diff != "" { @@ -74,7 +69,6 @@ func TestFilterFunc(t *testing.T) { req *types.LLMRequest input []types.Pod output []types.Pod - err bool }{ { name: "least queuing empty input", @@ -193,11 +187,8 @@ func TestFilterFunc(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ctx := types.NewContext(context.Background(), test.req, test.input) - got, err := test.f(ctx, test.input) - if test.err != (err != nil) { - t.Errorf("Unexpected error, got %v, want %v", err, test.err) - } + ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) + got := test.f(ctx, test.input) opt := cmp.AllowUnexported(types.PodMetrics{}) if diff := cmp.Diff(test.output, got, opt); diff != "" { @@ -254,7 +245,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, }, } - ctx := types.NewContext(context.Background(), req, pods) + ctx := types.NewSchedulingContext(context.Background(), req, pods) // Run the filter function multiple times and count the results affinityCount := 0 @@ -265,10 +256,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { expectedAvailabilityPercent := 100 - expectedAffinityPercent for i := 0; i < numIterations; i++ { - result, err := loRASoftAffinityFilterFunc(ctx, pods) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } + result := loRASoftAffinityFilterFunc(ctx, pods) // Check which type of pod was returned if len(result) != 1 { diff --git a/pkg/epp/scheduling/plugins/noop.go b/pkg/epp/scheduling/plugins/noop.go index 1abcb95b..8f50ff36 100644 --- a/pkg/epp/scheduling/plugins/noop.go +++ b/pkg/epp/scheduling/plugins/noop.go @@ -27,12 +27,16 @@ type NoopPlugin struct{} func (p *NoopPlugin) Name() string { return "NoopPlugin" } -func (p *NoopPlugin) Score(ctx *types.Context, pod types.Pod) (float64, error) { return 0.0, nil } +func (p *NoopPlugin) PreSchedule(ctx *types.SchedulingContext) {} -func (p *NoopPlugin) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func (p *NoopPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) ([]types.Pod, error) { return pods, nil } -func (p *NoopPlugin) PreSchedule(ctx *types.Context) {} +func (p *NoopPlugin) Score(ctx *types.SchedulingContext, pod types.Pod) (float64, error) { + return 0.0, nil +} + +func (p *NoopPlugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) {} -func (p *NoopPlugin) PostSchedule(ctx *types.Context, res *types.Result) {} +func (p *NoopPlugin) PostResponse(ctx *types.SchedulingContext, pod types.Pod) {} diff --git a/pkg/epp/scheduling/plugins/picker.go b/pkg/epp/scheduling/plugins/picker/random_picker.go similarity index 86% rename from pkg/epp/scheduling/plugins/picker.go rename to pkg/epp/scheduling/plugins/picker/random_picker.go index 569e4e86..850108e7 100644 --- a/pkg/epp/scheduling/plugins/picker.go +++ b/pkg/epp/scheduling/plugins/picker/random_picker.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package picker import ( "fmt" @@ -30,8 +30,8 @@ func (rp *RandomPicker) Name() string { return "random" } -func (rp *RandomPicker) Pick(ctx *types.Context, pods []types.Pod) (*types.Result, error) { +func (rp *RandomPicker) Pick(ctx *types.SchedulingContext, pods []types.Pod) *types.Result { ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) i := rand.Intn(len(pods)) - return &types.Result{TargetPod: pods[i]}, nil + return &types.Result{TargetPod: pods[i]} } diff --git a/pkg/epp/scheduling/types/interfaces.go b/pkg/epp/scheduling/plugins/plugins.go similarity index 70% rename from pkg/epp/scheduling/types/interfaces.go rename to pkg/epp/scheduling/plugins/plugins.go index 6e954cef..4b334803 100644 --- a/pkg/epp/scheduling/types/interfaces.go +++ b/pkg/epp/scheduling/plugins/plugins.go @@ -14,28 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -package types +package plugins import ( - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) const ( PreSchedulerPluginType = "PreSchedule" - PostSchedulePluginType = "PostSchedule" FilterPluginType = "Filter" ScorerPluginType = "Scorer" + PostSchedulePluginType = "PostSchedule" PickerPluginType = "Picker" + PostResponsePluginType = "PostResponse" ) -type Pod interface { - GetPod() *backendmetrics.Pod - GetMetrics() *backendmetrics.Metrics - SetScore(float64) - Score() float64 - String() string -} - // Plugin defines the interface for scheduler plugins, combining scoring, filtering, // and event handling capabilities. type Plugin interface { @@ -47,29 +40,36 @@ type Plugin interface { // initialization work. type PreSchedule interface { Plugin - PreSchedule(ctx *Context) -} - -// PostSchedule is called by the scheduler after it selects a targetPod for the request. -type PostSchedule interface { - Plugin - PostSchedule(ctx *Context, res *Result) + PreSchedule(ctx *types.SchedulingContext) } // Filter defines the interface for filtering a list of pods based on context. type Filter interface { Plugin - Filter(ctx *Context, pods []Pod) ([]Pod, error) + Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod } // Scorer defines the interface for scoring pods based on context. type Scorer interface { Plugin - Score(ctx *Context, pod Pod) (float64, error) + Score(ctx *types.SchedulingContext, pod types.Pod) float64 +} + +// PostSchedule is called by the scheduler after it selects a targetPod for the request. +type PostSchedule interface { + Plugin + PostSchedule(ctx *types.SchedulingContext, res *types.Result) } // Picker picks the final pod(s) to send the request to. type Picker interface { Plugin - Pick(ctx *Context, pods []Pod) (*Result, error) + Pick(ctx *types.SchedulingContext, pods []types.Pod) *types.Result +} + +// PostResponse is called by the scheduler after a successful response was sent. +// The given pod argument is the pod that served the request. +type PostResponse interface { + Plugin + PostResponse(ctx *types.SchedulingContext, pod types.Pod) } diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 7cc2bd96..beac5e6b 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -26,42 +26,44 @@ import ( backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) var ( - lowLatencyFilter = &plugins.DecisionTreeFilter{ - Current: plugins.LowQueueFilter, - NextOnSuccess: &plugins.DecisionTreeFilter{ - Current: plugins.LoRAAffinityFilter, - NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ - Current: plugins.LeastQueueFilter, - NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ - Current: plugins.LeastKVCacheFilter, + lowLatencyFilter = &filter.DecisionTreeFilter{ + Current: filter.LowQueueFilter, + NextOnSuccess: &filter.DecisionTreeFilter{ + Current: filter.LoRAAffinityFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: filter.LeastQueueFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: filter.LeastKVCacheFilter, }, }, }, - NextOnFailure: &plugins.DecisionTreeFilter{ - Current: plugins.LeastQueueFilter, - NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ - Current: plugins.LoRAAffinityFilter, - NextOnSuccessOrFailure: &plugins.DecisionTreeFilter{ - Current: plugins.LeastKVCacheFilter, + NextOnFailure: &filter.DecisionTreeFilter{ + Current: filter.LeastQueueFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: filter.LoRAAffinityFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: filter.LeastKVCacheFilter, }, }, }, } - sheddableRequestFilter = &plugins.DecisionTreeFilter{ + sheddableRequestFilter = &filter.DecisionTreeFilter{ // When there is at least one model server that's not queuing requests, and still has KV // cache below a certain threshold, we consider this model server has capacity to handle // a sheddable request without impacting critical requests. - Current: plugins.HasCapacityFilter, + Current: filter.HasCapacityFilter, NextOnSuccess: lowLatencyFilter, // If all pods are queuing or running above the KVCache threshold, we drop the sheddable - // request to make room for critical requests. - NextOnFailure: plugins.DropRequestFilter, + // request to make room for critical requests. for this, we don't define nextOnFailure. } ) @@ -70,21 +72,21 @@ func NewScheduler(datastore Datastore) *Scheduler { return &Scheduler{ datastore: datastore, - preSchedulePlugins: []types.PreSchedule{}, - postSchedulePlugins: []types.PostSchedule{}, - scorers: []types.Scorer{}, - filters: []types.Filter{defaultPlugin}, + preSchedulePlugins: []plugins.PreSchedule{}, + scorers: []plugins.Scorer{}, + filters: []plugins.Filter{defaultPlugin}, + postSchedulePlugins: []plugins.PostSchedule{}, picker: defaultPlugin, } } type Scheduler struct { datastore Datastore - preSchedulePlugins []types.PreSchedule - postSchedulePlugins []types.PostSchedule - filters []types.Filter - scorers []types.Scorer - picker types.Picker + preSchedulePlugins []plugins.PreSchedule + filters []plugins.Filter + scorers []plugins.Scorer + postSchedulePlugins []plugins.PostSchedule + picker plugins.Picker } type Datastore interface { @@ -99,26 +101,21 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types // Snapshot pod metrics from the datastore to: // 1. Reduce concurrent access to the datastore. // 2. Ensure consistent data during the scheduling operation of a request. - sCtx := types.NewContext(ctx, req, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) + sCtx := types.NewSchedulingContext(ctx, req, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) loggerDebug.Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", sCtx.PodsSnapshot)) s.runPreSchedulePlugins(sCtx) - pods, err := s.runFilterPlugins(sCtx) - if err != nil { - return nil, err + pods := s.runFilterPlugins(sCtx) + if len(pods) == 0 { + return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: "failed to find a target pod"} } - if err := s.runScorerPlugins(sCtx, pods); err != nil { - return nil, err - } + s.runScorerPlugins(sCtx, pods) before := time.Now() - res, err := s.picker.Pick(sCtx, pods) - metrics.RecordSchedulerPluginProcessingLatency(types.PickerPluginType, s.picker.Name(), time.Since(before)) - if err != nil { - return nil, err - } + res := s.picker.Pick(sCtx, pods) + metrics.RecordSchedulerPluginProcessingLatency(plugins.PickerPluginType, s.picker.Name(), time.Since(before)) loggerDebug.Info("After running picker plugins", "result", res) s.runPostSchedulePlugins(sCtx, res) @@ -126,91 +123,79 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types return res, nil } -func (s *Scheduler) runPreSchedulePlugins(ctx *types.Context) { +func (s *Scheduler) runPreSchedulePlugins(ctx *types.SchedulingContext) { for _, plugin := range s.preSchedulePlugins { ctx.Logger.V(logutil.DEBUG).Info("Running pre-schedule plugin", "plugin", plugin.Name()) before := time.Now() plugin.PreSchedule(ctx) - metrics.RecordSchedulerPluginProcessingLatency(types.PreSchedulerPluginType, plugin.Name(), time.Since(before)) + metrics.RecordSchedulerPluginProcessingLatency(plugins.PreSchedulerPluginType, plugin.Name(), time.Since(before)) } } -func (s *Scheduler) runPostSchedulePlugins(ctx *types.Context, res *types.Result) { +func (s *Scheduler) runPostSchedulePlugins(ctx *types.SchedulingContext, res *types.Result) { for _, plugin := range s.postSchedulePlugins { ctx.Logger.V(logutil.DEBUG).Info("Running post-schedule plugin", "plugin", plugin.Name()) before := time.Now() plugin.PostSchedule(ctx, res) - metrics.RecordSchedulerPluginProcessingLatency(types.PostSchedulePluginType, plugin.Name(), time.Since(before)) + metrics.RecordSchedulerPluginProcessingLatency(plugins.PostSchedulePluginType, plugin.Name(), time.Since(before)) } } -func (s *Scheduler) runFilterPlugins(ctx *types.Context) ([]types.Pod, error) { +func (s *Scheduler) runFilterPlugins(ctx *types.SchedulingContext) []types.Pod { loggerDebug := ctx.Logger.V(logutil.DEBUG) - pods := ctx.PodsSnapshot - loggerDebug.Info("Before running filter plugins", "pods", pods) + filteredPods := ctx.PodsSnapshot + loggerDebug.Info("Before running filter plugins", "pods", filteredPods) + for _, filter := range s.filters { loggerDebug.Info("Running filter plugin", "plugin", filter.Name()) before := time.Now() - filteredPods, err := filter.Filter(ctx, pods) - metrics.RecordSchedulerPluginProcessingLatency(types.FilterPluginType, filter.Name(), time.Since(before)) - if err != nil || len(filteredPods) == 0 { - return nil, fmt.Errorf("failed to apply filter, resulted %v pods, this should never happen: %w", len(filteredPods), err) + filteredPods = filter.Filter(ctx, filteredPods) + metrics.RecordSchedulerPluginProcessingLatency(plugins.FilterPluginType, filter.Name(), time.Since(before)) + loggerDebug.Info("Filter plugin result", "plugin", filter.Name(), "pods", filteredPods) + if len(filteredPods) == 0 { + break } - pods = filteredPods - loggerDebug.Info("Filter plugin result", "plugin", filter.Name(), "pods", pods) } - loggerDebug.Info("After running filter plugins", "pods", pods) - return pods, nil + return filteredPods } -func (s *Scheduler) runScorerPlugins(ctx *types.Context, pods []types.Pod) error { +func (s *Scheduler) runScorerPlugins(ctx *types.SchedulingContext, pods []types.Pod) { loggerDebug := ctx.Logger.V(logutil.DEBUG) loggerDebug.Info("Before running score plugins", "pods", pods) for _, pod := range pods { - score, err := runScorersForPod(ctx, s.scorers, pod) - if err != nil { - return err - } + score := s.runScorersForPod(ctx, pod) pod.SetScore(score) } loggerDebug.Info("After running score plugins", "pods", pods) - return nil } // Iterate through each scorer in the chain and accumulate the scores. -func runScorersForPod(ctx *types.Context, scorers []types.Scorer, pod types.Pod) (float64, error) { +func (s *Scheduler) runScorersForPod(ctx *types.SchedulingContext, pod types.Pod) float64 { logger := ctx.Logger.WithValues("pod", pod.GetPod().NamespacedName).V(logutil.DEBUG) score := float64(0) - for _, scorer := range scorers { + for _, scorer := range s.scorers { logger.Info("Running scorer", "scorer", scorer.Name()) before := time.Now() - oneScore, err := scorer.Score(ctx, pod) - metrics.RecordSchedulerPluginProcessingLatency(types.ScorerPluginType, scorer.Name(), time.Since(before)) - if err != nil { - logger.Error(err, "Failed to calculate score for scorer", "scorer", scorer.Name()) - return 0, err - } + oneScore := scorer.Score(ctx, pod) + metrics.RecordSchedulerPluginProcessingLatency(plugins.ScorerPluginType, scorer.Name(), time.Since(before)) score += oneScore logger.Info("After scorer", "scorer", scorer.Name(), "score", oneScore, "total score", score) } - return score, nil + return score } type defaultPlugin struct { - plugins.RandomPicker + picker.RandomPicker } func (p *defaultPlugin) Name() string { return "DefaultPlugin" } -func (p *defaultPlugin) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { - req := ctx.Req - var filter types.Filter - if req.Critical { - filter = lowLatencyFilter - } else { - filter = sheddableRequestFilter +func (p *defaultPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + if ctx.Req.Critical { + return lowLatencyFilter.Filter(ctx, pods) } - return filter.Filter(ctx, pods) + + return sheddableRequestFilter.Filter(ctx, pods) } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 5a2265bf..cb729038 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -18,12 +18,12 @@ package scheduling import ( "context" - "errors" "testing" "github.com/google/go-cmp/cmp" k8stypes "k8s.io/apimachinery/pkg/types" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -247,30 +247,22 @@ func TestSchedulePlugins(t *testing.T) { ScoreRes: 0.8, FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}}, } - tpFilterErr := &TestPlugin{ - NameRes: "filter err", - FilterErr: errors.New("filter error"), - } - tpScorerErr := &TestPlugin{ - NameRes: "score err", - ScoreErr: errors.New("score err"), + tp_filterAll := &TestPlugin{ + NameRes: "filter all", + FilterRes: []k8stypes.NamespacedName{}, } pickerPlugin := &TestPlugin{ NameRes: "picker", PickRes: k8stypes.NamespacedName{Name: "pod1"}, } - pickerErr := &TestPlugin{ - NameRes: "picker err", - PickErr: errors.New("picker err"), - } tests := []struct { name string - preSchedulePlugins []types.PreSchedule - postSchedulePlugins []types.PostSchedule - filters []types.Filter - scorers []types.Scorer - picker types.Picker + preSchedulePlugins []plugins.PreSchedule + filters []plugins.Filter + scorers []plugins.Scorer + postSchedulePlugins []plugins.PostSchedule + picker plugins.Picker input []*backendmetrics.FakePodMetrics wantTargetPod k8stypes.NamespacedName targetPodScore float64 @@ -280,10 +272,10 @@ func TestSchedulePlugins(t *testing.T) { }{ { name: "all plugins executed successfully", - preSchedulePlugins: []types.PreSchedule{tp1, tp2}, - postSchedulePlugins: []types.PostSchedule{tp1, tp2}, - filters: []types.Filter{tp1, tp2}, - scorers: []types.Scorer{tp1, tp2}, + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp2}, + scorers: []plugins.Scorer{tp1, tp2}, + postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, picker: pickerPlugin, input: []*backendmetrics.FakePodMetrics{ {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -296,46 +288,19 @@ func TestSchedulePlugins(t *testing.T) { err: false, }, { - name: "filter error", - preSchedulePlugins: []types.PreSchedule{tp1, tp2}, - postSchedulePlugins: []types.PostSchedule{tp1, tp2}, - filters: []types.Filter{tp1, tpFilterErr}, - scorers: []types.Scorer{tp1, tp2}, - picker: pickerPlugin, - input: []*backendmetrics.FakePodMetrics{ - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, - }, - err: true, - }, - { - name: "scorer error", - preSchedulePlugins: []types.PreSchedule{tp1, tp2}, - postSchedulePlugins: []types.PostSchedule{tp1, tp2}, - filters: []types.Filter{tp1, tp2}, - scorers: []types.Scorer{tp1, tpScorerErr}, + name: "filter all", + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp_filterAll}, + scorers: []plugins.Scorer{tp1, tp2}, + postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, picker: pickerPlugin, input: []*backendmetrics.FakePodMetrics{ {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, }, - err: true, - }, - { - name: "picker error", - preSchedulePlugins: []types.PreSchedule{tp1, tp2}, - postSchedulePlugins: []types.PostSchedule{tp1, tp2}, - filters: []types.Filter{tp1, tp2}, - scorers: []types.Scorer{tp1, tp2}, - picker: pickerErr, - input: []*backendmetrics.FakePodMetrics{ - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, - }, - err: true, + numPodsToScore: 0, + err: true, // no available pods to server after filter all }, } @@ -343,26 +308,26 @@ func TestSchedulePlugins(t *testing.T) { t.Run(test.name, func(t *testing.T) { // Reset all plugins before each new test case. for _, plugin := range test.preSchedulePlugins { - plugin.(*TestPlugin).Reset() + plugin.(*TestPlugin).reset() } for _, plugin := range test.postSchedulePlugins { - plugin.(*TestPlugin).Reset() + plugin.(*TestPlugin).reset() } for _, plugin := range test.filters { - plugin.(*TestPlugin).Reset() + plugin.(*TestPlugin).reset() } for _, plugin := range test.scorers { - plugin.(*TestPlugin).Reset() + plugin.(*TestPlugin).reset() } - test.picker.(*TestPlugin).Reset() + test.picker.(*TestPlugin).reset() // Initialize the scheduler scheduler := &Scheduler{ datastore: &fakeDataStore{pods: test.input}, preSchedulePlugins: test.preSchedulePlugins, - postSchedulePlugins: test.postSchedulePlugins, filters: test.filters, scorers: test.scorers, + postSchedulePlugins: test.postSchedulePlugins, picker: test.picker, } @@ -397,13 +362,6 @@ func TestSchedulePlugins(t *testing.T) { } } - for _, plugin := range test.postSchedulePlugins { - tp, _ := plugin.(*TestPlugin) - if tp.PostScheduleCallCount != 1 { - t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", tp.NameRes, tp.PostScheduleCallCount) - } - } - for _, plugin := range test.filters { tp, _ := plugin.(*TestPlugin) if tp.FilterCallCount != 1 { @@ -418,6 +376,13 @@ func TestSchedulePlugins(t *testing.T) { } } + for _, plugin := range test.postSchedulePlugins { + tp, _ := plugin.(*TestPlugin) + if tp.PostScheduleCallCount != 1 { + t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", tp.NameRes, tp.PostScheduleCallCount) + } + } + tp, _ := test.picker.(*TestPlugin) if tp.PickCallCount != 1 { t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.NameRes, tp.PickCallCount) @@ -444,55 +409,49 @@ type TestPlugin struct { NameRes string ScoreCallCount int ScoreRes float64 - ScoreErr error FilterCallCount int FilterRes []k8stypes.NamespacedName - FilterErr error PreScheduleCallCount int PostScheduleCallCount int PickCallCount int PickRes k8stypes.NamespacedName - PickErr error } func (tp *TestPlugin) Name() string { return tp.NameRes } -func (tp *TestPlugin) Score(ctx *types.Context, pod types.Pod) (float64, error) { - tp.ScoreCallCount++ - return tp.ScoreRes, tp.ScoreErr +func (tp *TestPlugin) PreSchedule(ctx *types.SchedulingContext) { + tp.PreScheduleCallCount++ } -func (tp *TestPlugin) Filter(ctx *types.Context, pods []types.Pod) ([]types.Pod, error) { +func (tp *TestPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { tp.FilterCallCount++ - return findPods(ctx, tp.FilterRes...), tp.FilterErr + return findPods(ctx, tp.FilterRes...) } -func (tp *TestPlugin) PreSchedule(ctx *types.Context) { - tp.PreScheduleCallCount++ +func (tp *TestPlugin) Score(ctx *types.SchedulingContext, pod types.Pod) float64 { + tp.ScoreCallCount++ + return tp.ScoreRes } -func (tp *TestPlugin) PostSchedule(ctx *types.Context, res *types.Result) { +func (tp *TestPlugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { tp.PostScheduleCallCount++ } -func (tp *TestPlugin) Pick(ctx *types.Context, pods []types.Pod) (*types.Result, error) { +func (tp *TestPlugin) Pick(ctx *types.SchedulingContext, pods []types.Pod) *types.Result { tp.PickCallCount++ - if tp.PickErr != nil { - return nil, tp.PickErr - } pod := findPods(ctx, tp.PickRes)[0] - return &types.Result{TargetPod: pod}, nil + return &types.Result{TargetPod: pod} } -func (tp *TestPlugin) Reset() { +func (tp *TestPlugin) reset() { tp.PreScheduleCallCount = 0 - tp.PostScheduleCallCount = 0 tp.FilterCallCount = 0 tp.ScoreCallCount = 0 + tp.PostScheduleCallCount = 0 tp.PickCallCount = 0 } -func findPods(ctx *types.Context, names ...k8stypes.NamespacedName) []types.Pod { +func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) []types.Pod { res := []types.Pod{} for _, pod := range ctx.PodsSnapshot { for _, name := range names { diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index e52e9047..e66b5fb5 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -40,8 +40,16 @@ func (r *LLMRequest) String() string { return fmt.Sprintf("Model: %s, TargetModels: %v, ResolvedTargetModel: %s, Critical: %t, PromptLength: %v", r.Model, r.TargetModels, r.ResolvedTargetModel, r.Critical, len(r.Prompt)) } -// Context holds contextual information during a scheduling operation. -type Context struct { +type Pod interface { + GetPod() *backendmetrics.Pod + GetMetrics() *backendmetrics.Metrics + SetScore(float64) + Score() float64 + String() string +} + +// SchedulingContext holds contextual information during a scheduling operation. +type SchedulingContext struct { context.Context Logger logr.Logger Req *LLMRequest @@ -77,9 +85,9 @@ type PodMetrics struct { *backendmetrics.Metrics } -func NewContext(ctx context.Context, req *LLMRequest, pods []Pod) *Context { +func NewSchedulingContext(ctx context.Context, req *LLMRequest, pods []Pod) *SchedulingContext { logger := log.FromContext(ctx).WithValues("request", req) - return &Context{ + return &SchedulingContext{ Context: ctx, Logger: logger, Req: req, From 9317e9b8abdb078a1bc49ba23adf8de6849b2387 Mon Sep 17 00:00:00 2001 From: nayihz Date: Thu, 24 Apr 2025 01:41:46 +0800 Subject: [PATCH 236/260] filter irrelevant pod in pod_reconciler (#696) --- pkg/epp/controller/pod_reconciler.go | 22 ++++++++++++++++++++++ pkg/epp/datastore/datastore.go | 3 +++ 2 files changed, 25 insertions(+) diff --git a/pkg/epp/controller/pod_reconciler.go b/pkg/epp/controller/pod_reconciler.go index 6d1af8d9..5f1df10d 100644 --- a/pkg/epp/controller/pod_reconciler.go +++ b/pkg/epp/controller/pod_reconciler.go @@ -26,7 +26,9 @@ import ( "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" podutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/pod" @@ -63,8 +65,28 @@ func (c *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R } func (c *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { + filter := predicate.Funcs{ + CreateFunc: func(ce event.CreateEvent) bool { + pod := ce.Object.(*corev1.Pod) + return c.Datastore.PoolLabelsMatch(pod.GetLabels()) + }, + UpdateFunc: func(ue event.UpdateEvent) bool { + oldPod := ue.ObjectOld.(*corev1.Pod) + newPod := ue.ObjectNew.(*corev1.Pod) + return c.Datastore.PoolLabelsMatch(oldPod.GetLabels()) || c.Datastore.PoolLabelsMatch(newPod.GetLabels()) + }, + DeleteFunc: func(de event.DeleteEvent) bool { + pod := de.Object.(*corev1.Pod) + return c.Datastore.PoolLabelsMatch(pod.GetLabels()) + }, + GenericFunc: func(ge event.GenericEvent) bool { + pod := ge.Object.(*corev1.Pod) + return c.Datastore.PoolLabelsMatch(pod.GetLabels()) + }, + } return ctrl.NewControllerManagedBy(mgr). For(&corev1.Pod{}). + WithEventFilter(filter). Complete(c) } diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index f8378d25..22c50022 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -150,6 +150,9 @@ func (ds *datastore) PoolHasSynced() bool { func (ds *datastore) PoolLabelsMatch(podLabels map[string]string) bool { ds.poolAndModelsMu.RLock() defer ds.poolAndModelsMu.RUnlock() + if ds.pool == nil { + return false + } poolSelector := selectorFromInferencePoolSelector(ds.pool.Spec.Selector) podSet := labels.Set(podLabels) return poolSelector.Matches(podSet) From 9eeb2dccb0c01f8ca8adbd0a8ae94230001eea83 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Wed, 23 Apr 2025 14:30:31 -0700 Subject: [PATCH 237/260] EPP: Update GetRandomPod() to return nil if no pods exist (#731) Signed-off-by: Daneyon Hansen --- pkg/epp/handlers/request.go | 3 ++ pkg/epp/handlers/server.go | 3 ++ pkg/epp/handlers/streamingserver_test.go | 55 ++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index 9121b59a..8d30e543 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -138,6 +138,9 @@ func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *Requ // The above PR will address endpoint admission, but currently any request without a body will be // routed to a random upstream pod. pod := GetRandomPod(s.datastore) + if pod == nil { + return errutil.Error{Code: errutil.Internal, Msg: "no pods available in datastore"} + } pool, err := s.datastore.PoolGet() if err != nil { return err diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 2e3a35fe..5e23c7a0 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -449,6 +449,9 @@ func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed func GetRandomPod(ds datastore.Datastore) *backendmetrics.Pod { pods := ds.PodGetAll() + if len(pods) == 0 { + return nil + } number := rand.Intn(len(pods)) pod := pods[number] return pod.GetPod() diff --git a/pkg/epp/handlers/streamingserver_test.go b/pkg/epp/handlers/streamingserver_test.go index 72f7031a..23d2b68f 100644 --- a/pkg/epp/handlers/streamingserver_test.go +++ b/pkg/epp/handlers/streamingserver_test.go @@ -18,8 +18,14 @@ package handlers import ( "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -126,6 +132,55 @@ func TestRandomWeightedDraw(t *testing.T) { } } +func TestGetRandomPod(t *testing.T) { + tests := []struct { + name string + storePods []*corev1.Pod + expectNil bool + }{ + { + name: "No pods available", + storePods: []*corev1.Pod{}, + expectNil: true, + }, + { + name: "Single pod available", + storePods: []*corev1.Pod{ + {ObjectMeta: metav1.ObjectMeta{Name: "pod1"}}, + }, + expectNil: false, + }, + { + name: "Multiple pods available", + storePods: []*corev1.Pod{ + {ObjectMeta: metav1.ObjectMeta{Name: "pod1"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "pod2"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "pod3"}}, + }, + expectNil: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pmf := metrics.NewPodMetricsFactory(&metrics.FakePodMetricsClient{}, time.Millisecond) + ds := datastore.NewDatastore(t.Context(), pmf) + for _, pod := range test.storePods { + ds.PodUpdateOrAddIfNotExist(pod) + } + + gotPod := GetRandomPod(ds) + + if test.expectNil && gotPod != nil { + t.Errorf("expected nil pod, got: %v", gotPod) + } + if !test.expectNil && gotPod == nil { + t.Errorf("expected non-nil pod, got nil") + } + }) + } +} + func pointer(v int32) *int32 { return &v } From 4c7fd64da7e0e1b39c89d79ff33cce244e44871a Mon Sep 17 00:00:00 2001 From: Maya Barnea Date: Thu, 24 Apr 2025 18:48:31 +0300 Subject: [PATCH 238/260] Move filter and scorer plugins registration to a separate file (#729) * Move filters and scorers registration to filter/scorer specific files * Default scheduler config contains empty list of scorers Signed-off-by: Maya Barnea * Default plugin is not a scorer any more Signed-off-by: Maya Barnea * fix scheduler test + lint comments Signed-off-by: Maya Barnea --------- Signed-off-by: Maya Barnea --- pkg/epp/scheduling/config.go | 27 ++++++++++ pkg/epp/scheduling/default_config.go | 31 +++++++++++ pkg/epp/scheduling/scheduler.go | 18 ++++--- pkg/epp/scheduling/scheduler_test.go | 81 ++++++++++++++-------------- 4 files changed, 110 insertions(+), 47 deletions(-) create mode 100644 pkg/epp/scheduling/config.go create mode 100644 pkg/epp/scheduling/default_config.go diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go new file mode 100644 index 00000000..6c0f4be7 --- /dev/null +++ b/pkg/epp/scheduling/config.go @@ -0,0 +1,27 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling + +import "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + +type SchedulerConfig struct { + preSchedulePlugins []plugins.PreSchedule + scorers []plugins.Scorer + filters []plugins.Filter + postSchedulePlugins []plugins.PostSchedule + picker plugins.Picker +} diff --git a/pkg/epp/scheduling/default_config.go b/pkg/epp/scheduling/default_config.go new file mode 100644 index 00000000..e42f1317 --- /dev/null +++ b/pkg/epp/scheduling/default_config.go @@ -0,0 +1,31 @@ +/* +Copyright 2025 The Kubernetes 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 scheduling + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" +) + +var defPlugin = &defaultPlugin{} + +var defaultConfig = &SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{}, + scorers: []plugins.Scorer{}, + filters: []plugins.Filter{defPlugin}, + postSchedulePlugins: []plugins.PostSchedule{}, + picker: defPlugin, +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index beac5e6b..322f714f 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -68,16 +68,20 @@ var ( ) func NewScheduler(datastore Datastore) *Scheduler { - defaultPlugin := &defaultPlugin{} + return NewSchedulerWithConfig(datastore, defaultConfig) +} - return &Scheduler{ +func NewSchedulerWithConfig(datastore Datastore, config *SchedulerConfig) *Scheduler { + scheduler := &Scheduler{ datastore: datastore, - preSchedulePlugins: []plugins.PreSchedule{}, - scorers: []plugins.Scorer{}, - filters: []plugins.Filter{defaultPlugin}, - postSchedulePlugins: []plugins.PostSchedule{}, - picker: defaultPlugin, + preSchedulePlugins: config.preSchedulePlugins, + scorers: config.scorers, + filters: config.filters, + postSchedulePlugins: config.postSchedulePlugins, + picker: config.picker, } + + return scheduler } type Scheduler struct { diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index cb729038..2fb26a86 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -220,9 +220,17 @@ func TestSchedule(t *testing.T) { }, } + schedConfig := &SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{}, + scorers: []plugins.Scorer{}, + filters: []plugins.Filter{defPlugin}, + postSchedulePlugins: []plugins.PostSchedule{}, + picker: defPlugin, + } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { - scheduler := NewScheduler(&fakeDataStore{pods: test.input}) + scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, schedConfig) got, err := scheduler.Schedule(context.Background(), test.req) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) @@ -257,26 +265,24 @@ func TestSchedulePlugins(t *testing.T) { } tests := []struct { - name string - preSchedulePlugins []plugins.PreSchedule - filters []plugins.Filter - scorers []plugins.Scorer - postSchedulePlugins []plugins.PostSchedule - picker plugins.Picker - input []*backendmetrics.FakePodMetrics - wantTargetPod k8stypes.NamespacedName - targetPodScore float64 + name string + config SchedulerConfig + input []*backendmetrics.FakePodMetrics + wantTargetPod k8stypes.NamespacedName + targetPodScore float64 // Number of expected pods to score (after filter) numPodsToScore int err bool }{ { - name: "all plugins executed successfully", - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp2}, - scorers: []plugins.Scorer{tp1, tp2}, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, - picker: pickerPlugin, + name: "all plugins executed successfully", + config: SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp2}, + scorers: []plugins.Scorer{tp1, tp2}, + postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + picker: pickerPlugin, + }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, @@ -288,12 +294,14 @@ func TestSchedulePlugins(t *testing.T) { err: false, }, { - name: "filter all", - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp_filterAll}, - scorers: []plugins.Scorer{tp1, tp2}, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, - picker: pickerPlugin, + name: "filter all", + config: SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp_filterAll}, + scorers: []plugins.Scorer{tp1, tp2}, + postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + picker: pickerPlugin, + }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, @@ -307,29 +315,22 @@ func TestSchedulePlugins(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { // Reset all plugins before each new test case. - for _, plugin := range test.preSchedulePlugins { + for _, plugin := range test.config.preSchedulePlugins { plugin.(*TestPlugin).reset() } - for _, plugin := range test.postSchedulePlugins { + for _, plugin := range test.config.postSchedulePlugins { plugin.(*TestPlugin).reset() } - for _, plugin := range test.filters { + for _, plugin := range test.config.filters { plugin.(*TestPlugin).reset() } - for _, plugin := range test.scorers { + for _, plugin := range test.config.scorers { plugin.(*TestPlugin).reset() } - test.picker.(*TestPlugin).reset() + test.config.picker.(*TestPlugin).reset() // Initialize the scheduler - scheduler := &Scheduler{ - datastore: &fakeDataStore{pods: test.input}, - preSchedulePlugins: test.preSchedulePlugins, - filters: test.filters, - scorers: test.scorers, - postSchedulePlugins: test.postSchedulePlugins, - picker: test.picker, - } + scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) req := &types.LLMRequest{Model: "test-model"} got, err := scheduler.Schedule(context.Background(), req) @@ -355,35 +356,35 @@ func TestSchedulePlugins(t *testing.T) { } // Validate plugin execution counts dynamically - for _, plugin := range test.preSchedulePlugins { + for _, plugin := range test.config.preSchedulePlugins { tp, _ := plugin.(*TestPlugin) if tp.PreScheduleCallCount != 1 { t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", tp.NameRes, tp.PreScheduleCallCount) } } - for _, plugin := range test.filters { + for _, plugin := range test.config.filters { tp, _ := plugin.(*TestPlugin) if tp.FilterCallCount != 1 { t.Errorf("Plugin %s Filter() called %d times, expected 1", tp.NameRes, tp.FilterCallCount) } } - for _, plugin := range test.scorers { + for _, plugin := range test.config.scorers { tp, _ := plugin.(*TestPlugin) if tp.ScoreCallCount != test.numPodsToScore { t.Errorf("Plugin %s Score() called %d times, expected 1", tp.NameRes, tp.ScoreCallCount) } } - for _, plugin := range test.postSchedulePlugins { + for _, plugin := range test.config.postSchedulePlugins { tp, _ := plugin.(*TestPlugin) if tp.PostScheduleCallCount != 1 { t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", tp.NameRes, tp.PostScheduleCallCount) } } - tp, _ := test.picker.(*TestPlugin) + tp, _ := test.config.picker.(*TestPlugin) if tp.PickCallCount != 1 { t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.NameRes, tp.PickCallCount) } From c8d0d62d0a4584d0557e078263a279f4e86e7c27 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Thu, 24 Apr 2025 14:20:30 -0700 Subject: [PATCH 239/260] Update issue templates (#738) * Update issue templates * Updates artifacts for v0.3.0-rc.1 release Signed-off-by: Kellen Swain * Updates bbr chart for v0.3.0-rc.1 release Signed-off-by: Kellen Swain * Updates artifacts for v0.3.0 release Signed-off-by: Kellen Swain * Adding blank issue template so that all issues start with label --------- Signed-off-by: Kellen Swain --- .github/ISSUE_TEMPLATE/bug_request.md | 4 +++- .github/ISSUE_TEMPLATE/feature_request.md | 3 +-- .github/ISSUE_TEMPLATE/issue_template.md | 8 ++++++++ .github/ISSUE_TEMPLATE/new-release.md | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/issue_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_request.md b/.github/ISSUE_TEMPLATE/bug_request.md index c2597eb3..15ed35e1 100644 --- a/.github/ISSUE_TEMPLATE/bug_request.md +++ b/.github/ISSUE_TEMPLATE/bug_request.md @@ -1,7 +1,9 @@ --- name: Bug Report about: Report a bug you encountered -labels: kind/bug +title: '' +labels: kind/bug, needs-triage +assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 53a885c7..1eee5871 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: '' +labels: needs-triage assignees: '' --- @@ -12,4 +12,3 @@ assignees: '' **What would you like to be added**: **Why is this needed**: - diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md new file mode 100644 index 00000000..1a2c8c6f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -0,0 +1,8 @@ +--- +name: Blank Issue +about: '' +title: '' +labels: needs-triage +assignees: '' + +--- \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/new-release.md b/.github/ISSUE_TEMPLATE/new-release.md index be569844..27e83784 100644 --- a/.github/ISSUE_TEMPLATE/new-release.md +++ b/.github/ISSUE_TEMPLATE/new-release.md @@ -4,6 +4,7 @@ about: Propose a new release title: Release v0.x.0 labels: '' assignees: '' + --- - [Introduction](#introduction) From b66a61c4b4753b9c5dedc26c0772490c9da9907e Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 25 Apr 2025 10:31:02 -0400 Subject: [PATCH 240/260] docs: add concepts and definitions to README.md (#734) Signed-off-by: Shane Utt --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f7943d2f..ffd86758 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,56 @@ [![Go Reference](https://pkg.go.dev/badge/sigs.k8s.io/gateway-api-inference-extension.svg)](https://pkg.go.dev/sigs.k8s.io/gateway-api-inference-extension) [![License](https://img.shields.io/github/license/kubernetes-sigs/gateway-api-inference-extension)](/LICENSE) -# Gateway API Inference Extension +# Gateway API Inference Extension (GIE) + +This project offers tools for AI Inference, enabling developers to build [Inference Gateways]. + +[Inference Gateways]:#concepts-and-definitions + +## Concepts and Definitions + +The following are some key industry terms that are important to understand for +this project: + +- **Model**: A generative AI model that has learned patterns from data and is + used for inference. Models vary in size and architecture, from smaller + domain-specific models to massive multi-billion parameter neural networks that + are optimized for diverse language tasks. +- **Inference**: The process of running a generative AI model, such as a large + language model, diffusion model etc, to generate text, embeddings, or other + outputs from input data. +- **Model server**: A service (in our case, containerized) responsible for + receiving inference requests and returning predictions from a model. +- **Accelerator**: specialized hardware, such as Graphics Processing Units + (GPUs) that can be attached to Kubernetes nodes to speed up computations, + particularly for training and inference tasks. + +And the following are more specific terms to this project: + +- **Scheduler**: Makes decisions about which endpoint is optimal (best cost / + best performance) for an inference request based on `Metrics and Capabilities` + from [Model Serving](/docs/proposals/003-model-server-protocol/README.md). +- **Metrics and Capabilities**: Data provided by model serving platforms about + performance, availability and capabilities to optimize routing. Includes + things like [Prefix Cache] status or [LoRA Adapters] availability. +- **Endpoint Selector**: A `Scheduler` combined with `Metrics and Capabilities` + systems is often referred to together as an [Endpoint Selection Extension] + (this is also sometimes referred to as an "endpoint picker", or "EPP"). +- **Inference Gateway**: A proxy/load-balancer which has been coupled with a + `Endpoint Selector`. It provides optimized routing and load balancing for + serving Kubernetes self-hosted generative Artificial Intelligence (AI) + workloads. It simplifies the deployment, management, and observability of AI + inference workloads. + +For deeper insights and more advanced concepts, refer to our [proposals](/docs/proposals). + +[Inference]:https://www.digitalocean.com/community/tutorials/llm-inference-optimization +[Gateway API]:https://github.com/kubernetes-sigs/gateway-api +[Prefix Cache]:https://docs.vllm.ai/en/stable/design/v1/prefix_caching.html +[LoRA Adapters]:https://docs.vllm.ai/en/stable/features/lora.html +[Endpoint Selection Extension]:https://gateway-api-inference-extension.sigs.k8s.io/#endpoint-selection-extension + +## Technical Overview This extension upgrades an [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter)-capable proxy or gateway - such as Envoy Gateway, kGateway, or the GKE Gateway - to become an **inference gateway** - supporting inference platform teams self-hosting large language models on Kubernetes. This integration makes it easy to expose and control access to your local [OpenAI-compatible chat completion endpoints](https://platform.openai.com/docs/api-reference/chat) to other workloads on or off cluster, or to integrate your self-hosted models alongside model-as-a-service providers in a higher level **AI Gateway** like LiteLLM, Solo AI Gateway, or Apigee. From 772ac4d69da2049304797a749f87511f61380660 Mon Sep 17 00:00:00 2001 From: Radhika Lakhtakia <137429298+rlakhtakia@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:15:24 +0000 Subject: [PATCH 241/260] Add unit tests for pod APIs under pkg/datastore (#712) * Add unit test coverage for pod APIs under datastore/pkg * Add unit test coverage for pod APIs under datastore/pkg * Add unit test coverage for pod APIs under datastore/pkg * Add unit test coverage for pod APIs under datastore/pkg * EPP Architecture proposal (#683) * initial changes * Adding to proposal to give a quick barebones definition to refactor * feedback changes * more feedback addressing * removed unused Fake struct (#723) Signed-off-by: Nir Rozenbaum * epp: return correct response for trailers (#726) This looks like a copy paste error. * Refactor scheduler to run plugins (#677) * Refactor scheduler to run plugins * Add scheduler plugin latency metric * Address comments * Address comments * Complete the InferencePool documentation (#673) * Initial guide for inference pool * Add extensionReference to the InferencePool spec * Fix list formatting * Remove unused labels * Autogenerate the spec * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Update site-src/api-types/inferencepool.md Co-authored-by: Rob Scott * Rename llm-pool names in rollout example * Add use cases for replacing an inference pool * Rewording the background section * Create replacing-inference-pool.md * Replace instructions with a link for how to replace an inference pool * Update replacing-inference-pool.md * Update mkdocs.yml * Update replacing-inference-pool.md * Update inferencemodel_types.go * Update inferencepool.md * Update site-src/guides/replacing-inference-pool.md Co-authored-by: Rob Scott --------- Co-authored-by: Rob Scott * reduce log level in metrics logger not to trash the log (#708) * reduce log level in metrics logger not to trash the log Signed-off-by: Nir Rozenbaum * rename flush metrics to refresh metrics Signed-off-by: Nir Rozenbaum * revert log level Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum * few updates in datastore (#713) * few updates in datastore Signed-off-by: Nir Rozenbaum * PoolSet documentation Signed-off-by: Nir Rozenbaum * error phrasing Signed-off-by: Nir Rozenbaum * removed unused pool arg from PodUpdateOrAddIfNotExist Signed-off-by: Nir Rozenbaum * linter Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum * scheduler refactoring (#730) Signed-off-by: Nir Rozenbaum * filter irrelevant pod in pod_reconciler (#696) * EPP: Update GetRandomPod() to return nil if no pods exist (#731) Signed-off-by: Daneyon Hansen * Move filter and scorer plugins registration to a separate file (#729) * Move filters and scorers registration to filter/scorer specific files * Default scheduler config contains empty list of scorers Signed-off-by: Maya Barnea * Default plugin is not a scorer any more Signed-off-by: Maya Barnea * fix scheduler test + lint comments Signed-off-by: Maya Barnea --------- Signed-off-by: Maya Barnea * Update issue templates (#738) * Update issue templates * Updates artifacts for v0.3.0-rc.1 release Signed-off-by: Kellen Swain * Updates bbr chart for v0.3.0-rc.1 release Signed-off-by: Kellen Swain * Updates artifacts for v0.3.0 release Signed-off-by: Kellen Swain * Adding blank issue template so that all issues start with label --------- Signed-off-by: Kellen Swain * Add unit test coverage for pod APIs under datastore/pkg * few updates in datastore (#713) * few updates in datastore Signed-off-by: Nir Rozenbaum * PoolSet documentation Signed-off-by: Nir Rozenbaum * error phrasing Signed-off-by: Nir Rozenbaum * removed unused pool arg from PodUpdateOrAddIfNotExist Signed-off-by: Nir Rozenbaum * linter Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum * few updates in datastore (#713) * few updates in datastore Signed-off-by: Nir Rozenbaum * PoolSet documentation Signed-off-by: Nir Rozenbaum * error phrasing Signed-off-by: Nir Rozenbaum * removed unused pool arg from PodUpdateOrAddIfNotExist Signed-off-by: Nir Rozenbaum * linter Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum * Add unit test coverage for pod APIs under datastore/pkg --------- Signed-off-by: Nir Rozenbaum Signed-off-by: Daneyon Hansen Signed-off-by: Maya Barnea Signed-off-by: Kellen Swain Co-authored-by: Kellen Swain Co-authored-by: Nir Rozenbaum Co-authored-by: John Howard Co-authored-by: Cong Liu Co-authored-by: Nicole Xin Co-authored-by: Rob Scott Co-authored-by: nayihz Co-authored-by: Daneyon Hansen Co-authored-by: Maya Barnea --- pkg/epp/datastore/datastore_test.go | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index e8c77d37..b6466e6b 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -355,3 +355,94 @@ func TestMetrics(t *testing.T) { }) } } + +func TestPods(t *testing.T) { + updatedPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + }, + Spec: corev1.PodSpec{ + NodeName: "node-1", + }, + } + tests := []struct { + name string + op func(ctx context.Context, ds Datastore) + existingPods []*corev1.Pod + wantPods []*corev1.Pod + }{ + { + name: "Add new pod, no existing pods, should add", + existingPods: []*corev1.Pod{}, + wantPods: []*corev1.Pod{pod1}, + op: func(ctx context.Context, ds Datastore) { + ds.PodUpdateOrAddIfNotExist(pod1) + }, + }, + { + name: "Add new pod, with existing pods, should add", + existingPods: []*corev1.Pod{pod1}, + wantPods: []*corev1.Pod{pod1, pod2}, + op: func(ctx context.Context, ds Datastore) { + ds.PodUpdateOrAddIfNotExist(pod2) + }, + }, + { + name: "Update existing pod, new field, should update", + existingPods: []*corev1.Pod{pod1}, + wantPods: []*corev1.Pod{updatedPod}, + op: func(ctx context.Context, ds Datastore) { + ds.PodUpdateOrAddIfNotExist(updatedPod) + }, + }, + { + name: "Update existing pod, no new fields, should not update", + existingPods: []*corev1.Pod{pod1}, + wantPods: []*corev1.Pod{pod1}, + op: func(ctx context.Context, ds Datastore) { + incoming := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + }, + } + ds.PodUpdateOrAddIfNotExist(incoming) + }, + }, + { + name: "Delete the pod", + wantPods: []*corev1.Pod{pod1}, + op: func(ctx context.Context, ds Datastore) { + ds.PodDelete(pod2NamespacedName) + }, + }, + { + name: "Delete the pod that doesn't exist", + existingPods: []*corev1.Pod{pod1}, + wantPods: []*corev1.Pod{pod1}, + op: func(ctx context.Context, ds Datastore) { + ds.PodDelete(pod2NamespacedName) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) + ds := NewDatastore(t.Context(), pmf) + for _, pod := range test.existingPods { + ds.PodUpdateOrAddIfNotExist(pod) + } + + test.op(ctx, ds) + var gotPods []*corev1.Pod + for _, pm := range ds.PodGetAll() { + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: pm.GetPod().NamespacedName.Name, Namespace: pm.GetPod().NamespacedName.Namespace}, Status: corev1.PodStatus{PodIP: pm.GetPod().Address}} + gotPods = append(gotPods, pod) + } + if !cmp.Equal(gotPods, test.wantPods, cmpopts.SortSlices(func(a, b *corev1.Pod) bool { return a.Name < b.Name })) { + t.Logf("got (%v) != want (%v);", gotPods, test.wantPods) + } + }) + } +} From 60f8c57bb95b656a75d27564d5ff01c060bcdba5 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 25 Apr 2025 21:37:23 +0300 Subject: [PATCH 242/260] added a target dedicated for running unit-test only (#739) * added a target dedicated for running unit-test only. this is very useful during development. Signed-off-by: Nir Rozenbaum * code review Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a1845560..563e0ce9 100644 --- a/Makefile +++ b/Makefile @@ -123,8 +123,12 @@ vet: ## Run go vet against code. test: manifests generate fmt vet envtest image-build ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -race -coverprofile cover.out +.PHONY: test-unit +test-unit: ## Run unit tests. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./pkg/... -race -coverprofile cover.out + .PHONY: test-integration -test-integration: ## Run tests. +test-integration: ## Run integration tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./test/integration/epp/... -race -coverprofile cover.out .PHONY: test-e2e From 1a871729f3af64840aac85b4cf7f861880f35a8a Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Fri, 25 Apr 2025 12:25:23 -0700 Subject: [PATCH 243/260] Updating proposal directories to match their PR number (#741) --- .../README.md | 0 .../images/epp_arch.svg | 0 docs/proposals/README.md | 5 +++++ 3 files changed, 5 insertions(+) rename docs/proposals/{00x-epp-compliance-proposal => 0683-epp-architecture-proposal}/README.md (100%) rename docs/proposals/{00x-epp-compliance-proposal => 0683-epp-architecture-proposal}/images/epp_arch.svg (100%) create mode 100644 docs/proposals/README.md diff --git a/docs/proposals/00x-epp-compliance-proposal/README.md b/docs/proposals/0683-epp-architecture-proposal/README.md similarity index 100% rename from docs/proposals/00x-epp-compliance-proposal/README.md rename to docs/proposals/0683-epp-architecture-proposal/README.md diff --git a/docs/proposals/00x-epp-compliance-proposal/images/epp_arch.svg b/docs/proposals/0683-epp-architecture-proposal/images/epp_arch.svg similarity index 100% rename from docs/proposals/00x-epp-compliance-proposal/images/epp_arch.svg rename to docs/proposals/0683-epp-architecture-proposal/images/epp_arch.svg diff --git a/docs/proposals/README.md b/docs/proposals/README.md new file mode 100644 index 00000000..2b0408d3 --- /dev/null +++ b/docs/proposals/README.md @@ -0,0 +1,5 @@ +# Proposals Best Practices + + +## Naming +The directory of the proposal should lead with a 4-digit PR number (will move to 5,6,... should our PR count get that high), followed by kebab-cased title. The PR number is not known until the PR is cut, so development can use a placeholder, ex. XXXX-my-proposal. PR number is used b/c it is unique & chronological, allowing the default ordering of proposals to follow the timeline of development. \ No newline at end of file From ddc3d6992d41f515ef31d6d67fbba5c8aacb451c Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Fri, 25 Apr 2025 12:43:24 -0700 Subject: [PATCH 244/260] fixing errors in new template & disabling the default blank template (#742) --- .github/ISSUE_TEMPLATE/{issue_template.md => blank_issue.md} | 2 +- .github/ISSUE_TEMPLATE/config.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) rename .github/ISSUE_TEMPLATE/{issue_template.md => blank_issue.md} (64%) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/blank_issue.md similarity index 64% rename from .github/ISSUE_TEMPLATE/issue_template.md rename to .github/ISSUE_TEMPLATE/blank_issue.md index 1a2c8c6f..dd6ebabf 100644 --- a/.github/ISSUE_TEMPLATE/issue_template.md +++ b/.github/ISSUE_TEMPLATE/blank_issue.md @@ -1,6 +1,6 @@ --- name: Blank Issue -about: '' +about: Create a new issue from scratch title: '' labels: needs-triage assignees: '' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false From e845173f9488605b941caa532a4e98abd5cca640 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Sun, 27 Apr 2025 19:41:25 +0300 Subject: [PATCH 245/260] fixed broken link to implemenations (#750) Signed-off-by: Nir Rozenbaum --- site-src/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/index.md b/site-src/index.md index 04d1fadb..61bece27 100644 --- a/site-src/index.md +++ b/site-src/index.md @@ -91,7 +91,7 @@ This project is being driven by [WG-Serving](https://github.com/kubernetes/community/tree/master/wg-serving) [SIG-Network](https://github.com/kubernetes/community/tree/master/sig-network) to improve and standardize routing to inference workloads in Kubernetes. Check -out the [implementations reference](implementations.md) to see the latest +out the [implementations reference](implementations/gateways.md) to see the latest projects & products that support this project. If you are interested in contributing to or building an implementation using Gateway API then don’t hesitate to [get involved!](/contributing) From 855436e23577a6ef6d2dfe9ea2fe6668c9461838 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 28 Apr 2025 12:25:28 +0300 Subject: [PATCH 246/260] Weighted scorers (#737) * removed unused noop plugin Signed-off-by: Nir Rozenbaum * more scheduler refactoring Signed-off-by: Nir Rozenbaum * more refactoring Signed-off-by: Nir Rozenbaum * added weights to scorers and calculating weighted score Signed-off-by: Nir Rozenbaum * addressed code review comments Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/scheduling/config.go | 18 ++- pkg/epp/scheduling/default_config.go | 31 ---- .../scheduling/plugins/filter/filter_test.go | 6 +- pkg/epp/scheduling/plugins/noop.go | 42 ------ .../plugins/picker/random_picker.go | 12 +- pkg/epp/scheduling/plugins/plugins.go | 17 ++- pkg/epp/scheduling/scheduler.go | 96 +++++++----- pkg/epp/scheduling/scheduler_test.go | 141 ++++++++++++------ pkg/epp/scheduling/types/types.go | 16 +- 9 files changed, 190 insertions(+), 189 deletions(-) delete mode 100644 pkg/epp/scheduling/default_config.go delete mode 100644 pkg/epp/scheduling/plugins/noop.go diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index 6c0f4be7..4ed109af 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -20,8 +20,22 @@ import "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" type SchedulerConfig struct { preSchedulePlugins []plugins.PreSchedule - scorers []plugins.Scorer filters []plugins.Filter - postSchedulePlugins []plugins.PostSchedule + scorers map[plugins.Scorer]int // map from scorer to weight picker plugins.Picker + postSchedulePlugins []plugins.PostSchedule +} + +var defPlugin = &defaultPlugin{} + +// When the scheduler is initialized with NewScheduler function, this config will be used as default. +// it's possible to call NewSchedulerWithConfig to pass a different argument. + +// For build time plugins changes, it's recommended to change the defaultConfig variable in this file. +var defaultConfig = &SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{}, + filters: []plugins.Filter{defPlugin}, + scorers: map[plugins.Scorer]int{}, + picker: defPlugin, + postSchedulePlugins: []plugins.PostSchedule{}, } diff --git a/pkg/epp/scheduling/default_config.go b/pkg/epp/scheduling/default_config.go deleted file mode 100644 index e42f1317..00000000 --- a/pkg/epp/scheduling/default_config.go +++ /dev/null @@ -1,31 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 scheduling - -import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" -) - -var defPlugin = &defaultPlugin{} - -var defaultConfig = &SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{}, - scorers: []plugins.Scorer{}, - filters: []plugins.Filter{defPlugin}, - postSchedulePlugins: []plugins.PostSchedule{}, - picker: defPlugin, -} diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index 56cccb3b..a06ec3ca 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -54,8 +54,7 @@ func TestFilter(t *testing.T) { ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) got := test.filter.Filter(ctx, test.input) - opt := cmp.AllowUnexported(types.PodMetrics{}) - if diff := cmp.Diff(test.output, got, opt); diff != "" { + if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -190,8 +189,7 @@ func TestFilterFunc(t *testing.T) { ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) got := test.f(ctx, test.input) - opt := cmp.AllowUnexported(types.PodMetrics{}) - if diff := cmp.Diff(test.output, got, opt); diff != "" { + if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) diff --git a/pkg/epp/scheduling/plugins/noop.go b/pkg/epp/scheduling/plugins/noop.go deleted file mode 100644 index 8f50ff36..00000000 --- a/pkg/epp/scheduling/plugins/noop.go +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2025 The Kubernetes 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 plugins - -import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" -) - -// NoopPlugin provides a default, no-operation implementation of the Plugin interface. -// It can be embedded in other plugin implementations to avoid boilerplate code for -// unused methods. -type NoopPlugin struct{} - -func (p *NoopPlugin) Name() string { return "NoopPlugin" } - -func (p *NoopPlugin) PreSchedule(ctx *types.SchedulingContext) {} - -func (p *NoopPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) ([]types.Pod, error) { - return pods, nil -} - -func (p *NoopPlugin) Score(ctx *types.SchedulingContext, pod types.Pod) (float64, error) { - return 0.0, nil -} - -func (p *NoopPlugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) {} - -func (p *NoopPlugin) PostResponse(ctx *types.SchedulingContext, pod types.Pod) {} diff --git a/pkg/epp/scheduling/plugins/picker/random_picker.go b/pkg/epp/scheduling/plugins/picker/random_picker.go index 850108e7..6eecbb0d 100644 --- a/pkg/epp/scheduling/plugins/picker/random_picker.go +++ b/pkg/epp/scheduling/plugins/picker/random_picker.go @@ -20,18 +20,22 @@ import ( "fmt" "math/rand" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +var _ plugins.Picker = &RandomPicker{} + +// RandomPicker picks a random pod from the list of candidates. type RandomPicker struct{} func (rp *RandomPicker) Name() string { return "random" } -func (rp *RandomPicker) Pick(ctx *types.SchedulingContext, pods []types.Pod) *types.Result { - ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) - i := rand.Intn(len(pods)) - return &types.Result{TargetPod: pods[i]} +func (rp *RandomPicker) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { + ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(scoredPods), scoredPods)) + i := rand.Intn(len(scoredPods)) + return &types.Result{TargetPod: scoredPods[i].Pod} } diff --git a/pkg/epp/scheduling/plugins/plugins.go b/pkg/epp/scheduling/plugins/plugins.go index 4b334803..f3412ab7 100644 --- a/pkg/epp/scheduling/plugins/plugins.go +++ b/pkg/epp/scheduling/plugins/plugins.go @@ -49,22 +49,23 @@ type Filter interface { Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod } -// Scorer defines the interface for scoring pods based on context. +// Scorer defines the interface for scoring a list of pods based on context. +// Scorers must score pods with a value within the range of [0,1] where 1 is the highest score. type Scorer interface { Plugin - Score(ctx *types.SchedulingContext, pod types.Pod) float64 + Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 } -// PostSchedule is called by the scheduler after it selects a targetPod for the request. -type PostSchedule interface { +// Picker picks the final pod(s) to send the request to. +type Picker interface { Plugin - PostSchedule(ctx *types.SchedulingContext, res *types.Result) + Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result } -// Picker picks the final pod(s) to send the request to. -type Picker interface { +// PostSchedule is called by the scheduler after it selects a targetPod for the request. +type PostSchedule interface { Plugin - Pick(ctx *types.SchedulingContext, pods []types.Pod) *types.Result + PostSchedule(ctx *types.SchedulingContext, res *types.Result) } // PostResponse is called by the scheduler after a successful response was sent. diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 322f714f..04d24ea2 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -72,25 +72,23 @@ func NewScheduler(datastore Datastore) *Scheduler { } func NewSchedulerWithConfig(datastore Datastore, config *SchedulerConfig) *Scheduler { - scheduler := &Scheduler{ + return &Scheduler{ datastore: datastore, preSchedulePlugins: config.preSchedulePlugins, - scorers: config.scorers, filters: config.filters, - postSchedulePlugins: config.postSchedulePlugins, + scorers: config.scorers, picker: config.picker, + postSchedulePlugins: config.postSchedulePlugins, } - - return scheduler } type Scheduler struct { datastore Datastore preSchedulePlugins []plugins.PreSchedule filters []plugins.Filter - scorers []plugins.Scorer - postSchedulePlugins []plugins.PostSchedule + scorers map[plugins.Scorer]int // map from scorer to its weight picker plugins.Picker + postSchedulePlugins []plugins.PostSchedule } type Datastore interface { @@ -106,7 +104,7 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types // 1. Reduce concurrent access to the datastore. // 2. Ensure consistent data during the scheduling operation of a request. sCtx := types.NewSchedulingContext(ctx, req, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) - loggerDebug.Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", sCtx.PodsSnapshot)) + loggerDebug.Info(fmt.Sprintf("Scheduling a request, Metrics: %+v", sCtx.PodsSnapshot)) s.runPreSchedulePlugins(sCtx) @@ -114,17 +112,14 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types if len(pods) == 0 { return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: "failed to find a target pod"} } + // if we got here, there is at least one pod to score + weightedScorePerPod := s.runScorerPlugins(sCtx, pods) - s.runScorerPlugins(sCtx, pods) - - before := time.Now() - res := s.picker.Pick(sCtx, pods) - metrics.RecordSchedulerPluginProcessingLatency(plugins.PickerPluginType, s.picker.Name(), time.Since(before)) - loggerDebug.Info("After running picker plugins", "result", res) + result := s.runPickerPlugin(sCtx, weightedScorePerPod) - s.runPostSchedulePlugins(sCtx, res) + s.runPostSchedulePlugins(sCtx, result) - return res, nil + return result, nil } func (s *Scheduler) runPreSchedulePlugins(ctx *types.SchedulingContext) { @@ -136,15 +131,6 @@ func (s *Scheduler) runPreSchedulePlugins(ctx *types.SchedulingContext) { } } -func (s *Scheduler) runPostSchedulePlugins(ctx *types.SchedulingContext, res *types.Result) { - for _, plugin := range s.postSchedulePlugins { - ctx.Logger.V(logutil.DEBUG).Info("Running post-schedule plugin", "plugin", plugin.Name()) - before := time.Now() - plugin.PostSchedule(ctx, res) - metrics.RecordSchedulerPluginProcessingLatency(plugins.PostSchedulePluginType, plugin.Name(), time.Since(before)) - } -} - func (s *Scheduler) runFilterPlugins(ctx *types.SchedulingContext) []types.Pod { loggerDebug := ctx.Logger.V(logutil.DEBUG) filteredPods := ctx.PodsSnapshot @@ -160,32 +146,60 @@ func (s *Scheduler) runFilterPlugins(ctx *types.SchedulingContext) []types.Pod { break } } + loggerDebug.Info("After running filter plugins") + return filteredPods } -func (s *Scheduler) runScorerPlugins(ctx *types.SchedulingContext, pods []types.Pod) { +func (s *Scheduler) runScorerPlugins(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { loggerDebug := ctx.Logger.V(logutil.DEBUG) - loggerDebug.Info("Before running score plugins", "pods", pods) + loggerDebug.Info("Before running scorer plugins", "pods", pods) + + weightedScorePerPod := make(map[types.Pod]float64, len(pods)) for _, pod := range pods { - score := s.runScorersForPod(ctx, pod) - pod.SetScore(score) + weightedScorePerPod[pod] = float64(0) // initialize weighted score per pod with 0 value + } + // Iterate through each scorer in the chain and accumulate the weighted scores. + for scorer, weight := range s.scorers { + loggerDebug.Info("Running scorer", "scorer", scorer.Name()) + before := time.Now() + scores := scorer.Score(ctx, pods) + metrics.RecordSchedulerPluginProcessingLatency(plugins.ScorerPluginType, scorer.Name(), time.Since(before)) + for pod, score := range scores { // weight is relative to the sum of weights + weightedScorePerPod[pod] += score * float64(weight) // TODO normalize score before multiply with weight + } + loggerDebug.Info("After running scorer", "scorer", scorer.Name()) + } + loggerDebug.Info("After running scorer plugins") + + return weightedScorePerPod +} + +func (s *Scheduler) runPickerPlugin(ctx *types.SchedulingContext, weightedScorePerPod map[types.Pod]float64) *types.Result { + loggerDebug := ctx.Logger.V(logutil.DEBUG) + scoredPods := make([]*types.ScoredPod, len(weightedScorePerPod)) + i := 0 + for pod, score := range weightedScorePerPod { + scoredPods[i] = &types.ScoredPod{Pod: pod, Score: score} + i++ } - loggerDebug.Info("After running score plugins", "pods", pods) + + loggerDebug.Info("Before running picker plugin", "pods", weightedScorePerPod) + before := time.Now() + result := s.picker.Pick(ctx, scoredPods) + metrics.RecordSchedulerPluginProcessingLatency(plugins.PickerPluginType, s.picker.Name(), time.Since(before)) + loggerDebug.Info("After running picker plugin", "result", result) + + return result } -// Iterate through each scorer in the chain and accumulate the scores. -func (s *Scheduler) runScorersForPod(ctx *types.SchedulingContext, pod types.Pod) float64 { - logger := ctx.Logger.WithValues("pod", pod.GetPod().NamespacedName).V(logutil.DEBUG) - score := float64(0) - for _, scorer := range s.scorers { - logger.Info("Running scorer", "scorer", scorer.Name()) +func (s *Scheduler) runPostSchedulePlugins(ctx *types.SchedulingContext, res *types.Result) { + for _, plugin := range s.postSchedulePlugins { + ctx.Logger.V(logutil.DEBUG).Info("Running post-schedule plugin", "plugin", plugin.Name()) before := time.Now() - oneScore := scorer.Score(ctx, pod) - metrics.RecordSchedulerPluginProcessingLatency(plugins.ScorerPluginType, scorer.Name(), time.Since(before)) - score += oneScore - logger.Info("After scorer", "scorer", scorer.Name(), "score", oneScore, "total score", score) + plugin.PostSchedule(ctx, res) + metrics.RecordSchedulerPluginProcessingLatency(plugins.PostSchedulePluginType, plugin.Name(), time.Since(before)) } - return score } type defaultPlugin struct { diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 2fb26a86..559f53f8 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -220,24 +220,15 @@ func TestSchedule(t *testing.T) { }, } - schedConfig := &SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{}, - scorers: []plugins.Scorer{}, - filters: []plugins.Filter{defPlugin}, - postSchedulePlugins: []plugins.PostSchedule{}, - picker: defPlugin, - } - for _, test := range tests { t.Run(test.name, func(t *testing.T) { - scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, schedConfig) + scheduler := NewScheduler(&fakeDataStore{pods: test.input}) got, err := scheduler.Schedule(context.Background(), test.req) if test.err != (err != nil) { t.Errorf("Unexpected error, got %v, want %v", err, test.err) } - opt := cmp.AllowUnexported(types.PodMetrics{}) - if diff := cmp.Diff(test.wantRes, got, opt); diff != "" { + if diff := cmp.Diff(test.wantRes, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } }) @@ -275,13 +266,16 @@ func TestSchedulePlugins(t *testing.T) { err bool }{ { - name: "all plugins executed successfully", + name: "all plugins executed successfully, all scorers with same weight", config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp2}, - scorers: []plugins.Scorer{tp1, tp2}, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp2}, + scorers: map[plugins.Scorer]int{ + tp1: 1, + tp2: 1, + }, picker: pickerPlugin, + postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -294,13 +288,38 @@ func TestSchedulePlugins(t *testing.T) { err: false, }, { - name: "filter all", + name: "all plugins executed successfully, different scorers weights", config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp_filterAll}, - scorers: []plugins.Scorer{tp1, tp2}, + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp2}, + scorers: map[plugins.Scorer]int{ + tp1: 60, + tp2: 40, + }, + picker: pickerPlugin, postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + }, + input: []*backendmetrics.FakePodMetrics{ + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, + targetPodScore: 50, + numPodsToScore: 2, + err: false, + }, + { + name: "filter all", + config: SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + filters: []plugins.Filter{tp1, tp_filterAll}, + scorers: map[plugins.Scorer]int{ + tp1: 1, + tp2: 1, + }, picker: pickerPlugin, + postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -318,16 +337,16 @@ func TestSchedulePlugins(t *testing.T) { for _, plugin := range test.config.preSchedulePlugins { plugin.(*TestPlugin).reset() } - for _, plugin := range test.config.postSchedulePlugins { - plugin.(*TestPlugin).reset() - } for _, plugin := range test.config.filters { plugin.(*TestPlugin).reset() } - for _, plugin := range test.config.scorers { + for plugin := range test.config.scorers { plugin.(*TestPlugin).reset() } test.config.picker.(*TestPlugin).reset() + for _, plugin := range test.config.postSchedulePlugins { + plugin.(*TestPlugin).reset() + } // Initialize the scheduler scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) @@ -345,13 +364,11 @@ func TestSchedulePlugins(t *testing.T) { } // Validate output - opt := cmp.AllowUnexported(types.PodMetrics{}) wantPod := &types.PodMetrics{ Pod: &backendmetrics.Pod{NamespacedName: test.wantTargetPod}, } - wantPod.SetScore(test.targetPodScore) wantRes := &types.Result{TargetPod: wantPod} - if diff := cmp.Diff(wantRes, got, opt); diff != "" { + if diff := cmp.Diff(wantRes, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) } @@ -359,36 +376,44 @@ func TestSchedulePlugins(t *testing.T) { for _, plugin := range test.config.preSchedulePlugins { tp, _ := plugin.(*TestPlugin) if tp.PreScheduleCallCount != 1 { - t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", tp.NameRes, tp.PreScheduleCallCount) + t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", plugin.Name(), tp.PreScheduleCallCount) } } for _, plugin := range test.config.filters { tp, _ := plugin.(*TestPlugin) if tp.FilterCallCount != 1 { - t.Errorf("Plugin %s Filter() called %d times, expected 1", tp.NameRes, tp.FilterCallCount) + t.Errorf("Plugin %s Filter() called %d times, expected 1", plugin.Name(), tp.FilterCallCount) } } - for _, plugin := range test.config.scorers { + for plugin := range test.config.scorers { tp, _ := plugin.(*TestPlugin) - if tp.ScoreCallCount != test.numPodsToScore { - t.Errorf("Plugin %s Score() called %d times, expected 1", tp.NameRes, tp.ScoreCallCount) + if tp.ScoreCallCount != 1 { + t.Errorf("Plugin %s Score() called %d times, expected 1", plugin.Name(), tp.ScoreCallCount) } - } - - for _, plugin := range test.config.postSchedulePlugins { - tp, _ := plugin.(*TestPlugin) - if tp.PostScheduleCallCount != 1 { - t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", tp.NameRes, tp.PostScheduleCallCount) + if test.numPodsToScore != tp.NumOfScoredPods { + t.Errorf("Plugin %s Score() called with %d pods, expected %d", plugin.Name(), tp.NumOfScoredPods, test.numPodsToScore) } } tp, _ := test.config.picker.(*TestPlugin) + if tp.NumOfPickerCandidates != test.numPodsToScore { + t.Errorf("Picker plugin %s Pick() called with %d candidates, expected %d", tp.Name(), tp.NumOfPickerCandidates, tp.NumOfScoredPods) + } if tp.PickCallCount != 1 { - t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.NameRes, tp.PickCallCount) + t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.Name(), tp.PickCallCount) + } + if tp.WinnderPodScore != test.targetPodScore { + t.Errorf("winnder pod score %v, expected %v", tp.WinnderPodScore, test.targetPodScore) } + for _, plugin := range test.config.postSchedulePlugins { + tp, _ := plugin.(*TestPlugin) + if tp.PostScheduleCallCount != 1 { + t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", plugin.Name(), tp.PostScheduleCallCount) + } + } }) } } @@ -409,13 +434,16 @@ func (fds *fakeDataStore) PodGetAll() []backendmetrics.PodMetrics { type TestPlugin struct { NameRes string ScoreCallCount int + NumOfScoredPods int ScoreRes float64 FilterCallCount int FilterRes []k8stypes.NamespacedName PreScheduleCallCount int PostScheduleCallCount int PickCallCount int + NumOfPickerCandidates int PickRes k8stypes.NamespacedName + WinnderPodScore float64 } func (tp *TestPlugin) Name() string { return tp.NameRes } @@ -427,29 +455,39 @@ func (tp *TestPlugin) PreSchedule(ctx *types.SchedulingContext) { func (tp *TestPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { tp.FilterCallCount++ return findPods(ctx, tp.FilterRes...) -} -func (tp *TestPlugin) Score(ctx *types.SchedulingContext, pod types.Pod) float64 { - tp.ScoreCallCount++ - return tp.ScoreRes } -func (tp *TestPlugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { - tp.PostScheduleCallCount++ +func (tp *TestPlugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { + tp.ScoreCallCount++ + scoredPods := make(map[types.Pod]float64, len(pods)) + for _, pod := range pods { + scoredPods[pod] += tp.ScoreRes + } + tp.NumOfScoredPods = len(scoredPods) + return scoredPods } -func (tp *TestPlugin) Pick(ctx *types.SchedulingContext, pods []types.Pod) *types.Result { +func (tp *TestPlugin) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { tp.PickCallCount++ + tp.NumOfPickerCandidates = len(scoredPods) pod := findPods(ctx, tp.PickRes)[0] + tp.WinnderPodScore = getPodScore(scoredPods, pod) return &types.Result{TargetPod: pod} } +func (tp *TestPlugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { + tp.PostScheduleCallCount++ +} + func (tp *TestPlugin) reset() { tp.PreScheduleCallCount = 0 tp.FilterCallCount = 0 tp.ScoreCallCount = 0 + tp.NumOfScoredPods = 0 tp.PostScheduleCallCount = 0 tp.PickCallCount = 0 + tp.NumOfPickerCandidates = 0 } func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) []types.Pod { @@ -463,3 +501,14 @@ func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) [] } return res } + +func getPodScore(scoredPods []*types.ScoredPod, selectedPod types.Pod) float64 { + finalScore := 0.0 + for _, scoredPod := range scoredPods { + if scoredPod.Pod.GetPod().NamespacedName.String() == selectedPod.GetPod().NamespacedName.String() { + finalScore = scoredPod.Score + break + } + } + return finalScore +} diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index e66b5fb5..5ccfbdce 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -43,11 +43,14 @@ func (r *LLMRequest) String() string { type Pod interface { GetPod() *backendmetrics.Pod GetMetrics() *backendmetrics.Metrics - SetScore(float64) - Score() float64 String() string } +type ScoredPod struct { + Pod Pod + Score float64 +} + // SchedulingContext holds contextual information during a scheduling operation. type SchedulingContext struct { context.Context @@ -71,16 +74,7 @@ func (pm *PodMetrics) GetMetrics() *backendmetrics.Metrics { return pm.Metrics } -func (pm *PodMetrics) SetScore(score float64) { - pm.score = score -} - -func (pm *PodMetrics) Score() float64 { - return pm.score -} - type PodMetrics struct { - score float64 *backendmetrics.Pod *backendmetrics.Metrics } From cea06e2a02f6500f23758c2359ff64f4eb53e887 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 29 Apr 2025 00:07:54 +0300 Subject: [PATCH 247/260] add max score picker (#752) * embedded Pod interface into ScoredPod struct. updated tests and picker accordingly Signed-off-by: Nir Rozenbaum * implemented max-score picker Signed-off-by: Maroon Ayoub * minor changes in max score picker Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum Signed-off-by: Maroon Ayoub Co-authored-by: Maroon Ayoub --- .../plugins/picker/max_score_picker.go | 49 +++++++++++++++++++ .../plugins/picker/random_picker.go | 6 +-- pkg/epp/scheduling/scheduler_test.go | 46 +++++++++-------- pkg/epp/scheduling/types/types.go | 2 +- 4 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 pkg/epp/scheduling/plugins/picker/max_score_picker.go diff --git a/pkg/epp/scheduling/plugins/picker/max_score_picker.go b/pkg/epp/scheduling/plugins/picker/max_score_picker.go new file mode 100644 index 00000000..1705b7dd --- /dev/null +++ b/pkg/epp/scheduling/plugins/picker/max_score_picker.go @@ -0,0 +1,49 @@ +package picker + +import ( + "fmt" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +var _ plugins.Picker = &MaxScorePicker{} + +func NewMaxScorePicker() plugins.Picker { + return &MaxScorePicker{ + random: &RandomPicker{}, + } +} + +// MaxScorePicker picks the pod with the maximum score from the list of candidates. +type MaxScorePicker struct { + random *RandomPicker +} + +// Name returns the name of the picker. +func (p *MaxScorePicker) Name() string { + return "max_score" +} + +// Pick selects the pod with the maximum score from the list of candidates. +func (p *MaxScorePicker) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { + ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a pod with the max score from %d candidates: %+v", len(scoredPods), scoredPods)) + + highestScorePods := []*types.ScoredPod{} + maxScore := -1.0 // pods min score is 0, putting value lower than 0 in order to find at least one pod as highest + for _, pod := range scoredPods { + if pod.Score > maxScore { + maxScore = pod.Score + highestScorePods = []*types.ScoredPod{pod} + } else if pod.Score == maxScore { + highestScorePods = append(highestScorePods, pod) + } + } + + if len(highestScorePods) > 1 { + return p.random.Pick(ctx, highestScorePods) // pick randomly from the highest score pods + } + + return &types.Result{TargetPod: highestScorePods[0]} +} diff --git a/pkg/epp/scheduling/plugins/picker/random_picker.go b/pkg/epp/scheduling/plugins/picker/random_picker.go index 6eecbb0d..fb9f9a29 100644 --- a/pkg/epp/scheduling/plugins/picker/random_picker.go +++ b/pkg/epp/scheduling/plugins/picker/random_picker.go @@ -30,12 +30,12 @@ var _ plugins.Picker = &RandomPicker{} // RandomPicker picks a random pod from the list of candidates. type RandomPicker struct{} -func (rp *RandomPicker) Name() string { +func (p *RandomPicker) Name() string { return "random" } -func (rp *RandomPicker) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { +func (p *RandomPicker) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(scoredPods), scoredPods)) i := rand.Intn(len(scoredPods)) - return &types.Result{TargetPod: scoredPods[i].Pod} + return &types.Result{TargetPod: scoredPods[i]} } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 559f53f8..311f44e9 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -93,17 +93,19 @@ func TestSchedule(t *testing.T) { }, }, wantRes: &types.Result{ - TargetPod: &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.1, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, + TargetPod: &types.ScoredPod{ + Pod: &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.1, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + WaitingModels: map[string]int{}, }, - WaitingModels: map[string]int{}, }, }, }, @@ -154,17 +156,19 @@ func TestSchedule(t *testing.T) { }, }, wantRes: &types.Result{ - TargetPod: &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, + TargetPod: &types.ScoredPod{ + Pod: &types.PodMetrics{ + Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Metrics: &backendmetrics.Metrics{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + WaitingModels: map[string]int{}, }, - WaitingModels: map[string]int{}, }, }, }, @@ -505,7 +509,7 @@ func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) [] func getPodScore(scoredPods []*types.ScoredPod, selectedPod types.Pod) float64 { finalScore := 0.0 for _, scoredPod := range scoredPods { - if scoredPod.Pod.GetPod().NamespacedName.String() == selectedPod.GetPod().NamespacedName.String() { + if scoredPod.GetPod().NamespacedName.String() == selectedPod.GetPod().NamespacedName.String() { finalScore = scoredPod.Score break } diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index 5ccfbdce..5198515b 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -47,7 +47,7 @@ type Pod interface { } type ScoredPod struct { - Pod Pod + Pod Score float64 } From 06bd4223e28bb576b0b7ac51d3e0d9805d4cbd14 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 28 Apr 2025 16:53:53 -0700 Subject: [PATCH 248/260] Add GetEnvString helper function (#758) --- pkg/epp/util/env/env.go | 24 +++++++++----- pkg/epp/util/env/env_test.go | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/pkg/epp/util/env/env.go b/pkg/epp/util/env/env.go index 11e3bde1..0c6d1c6d 100644 --- a/pkg/epp/util/env/env.go +++ b/pkg/epp/util/env/env.go @@ -5,26 +5,25 @@ import ( "strconv" "github.com/go-logr/logr" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // getEnvFloat gets a float64 from an environment variable with a default value func GetEnvFloat(key string, defaultVal float64, logger logr.Logger) float64 { val, exists := os.LookupEnv(key) if !exists { - logger.V(logutil.VERBOSE).Info("Environment variable not set, using default value", + logger.Info("Environment variable not set, using default value", "key", key, "defaultValue", defaultVal) return defaultVal } floatVal, err := strconv.ParseFloat(val, 64) if err != nil { - logger.V(logutil.VERBOSE).Info("Failed to parse environment variable as float, using default value", + logger.Info("Failed to parse environment variable as float, using default value", "key", key, "value", val, "error", err, "defaultValue", defaultVal) return defaultVal } - logger.V(logutil.VERBOSE).Info("Successfully loaded environment variable", + logger.Info("Successfully loaded environment variable", "key", key, "value", floatVal) return floatVal } @@ -33,19 +32,30 @@ func GetEnvFloat(key string, defaultVal float64, logger logr.Logger) float64 { func GetEnvInt(key string, defaultVal int, logger logr.Logger) int { val, exists := os.LookupEnv(key) if !exists { - logger.V(logutil.VERBOSE).Info("Environment variable not set, using default value", + logger.Info("Environment variable not set, using default value", "key", key, "defaultValue", defaultVal) return defaultVal } intVal, err := strconv.Atoi(val) if err != nil { - logger.V(logutil.VERBOSE).Info("Failed to parse environment variable as int, using default value", + logger.Info("Failed to parse environment variable as int, using default value", "key", key, "value", val, "error", err, "defaultValue", defaultVal) return defaultVal } - logger.V(logutil.VERBOSE).Info("Successfully loaded environment variable", + logger.Info("Successfully loaded environment variable", "key", key, "value", intVal) return intVal } + +// GetEnvString gets a string from an environment variable with a default value +func GetEnvString(key string, defaultVal string, logger logr.Logger) string { + val, exists := os.LookupEnv(key) + if !exists { + logger.Info("Environment variable not set, using default value", + "key", key, "defaultValue", defaultVal) + return defaultVal + } + return val +} diff --git a/pkg/epp/util/env/env_test.go b/pkg/epp/util/env/env_test.go index 02513e28..105beb28 100644 --- a/pkg/epp/util/env/env_test.go +++ b/pkg/epp/util/env/env_test.go @@ -142,3 +142,64 @@ func TestGetEnvInt(t *testing.T) { }) } } + +func TestGetEnvString(t *testing.T) { + logger := testr.New(t) + + tests := []struct { + name string + key string + value string + defaultVal string + expected string + setup func() + teardown func() + }{ + { + name: "env variable exists and is valid", + key: "TEST_STR", + value: "123", + defaultVal: "default", + expected: "123", + setup: func() { + os.Setenv("TEST_STR", "123") + }, + teardown: func() { + os.Unsetenv("TEST_STR") + }, + }, + { + name: "env variable does not exist", + key: "TEST_STR_MISSING", + defaultVal: "default", + expected: "default", + setup: func() {}, + teardown: func() {}, + }, + { + name: "env variable is empty string", + key: "TEST_STR_EMPTY", + value: "", + defaultVal: "default", + expected: "", + setup: func() { + os.Setenv("TEST_STR_EMPTY", "") + }, + teardown: func() { + os.Unsetenv("TEST_STR_EMPTY") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + defer tc.teardown() + + result := GetEnvString(tc.key, tc.defaultVal, logger.V(logutil.VERBOSE)) + if result != tc.expected { + t.Errorf("GetEnvString(%s, %s) = %s, expected %s", tc.key, tc.defaultVal, result, tc.expected) + } + }) + } +} From e12c61718367e058788fbe851104cd7de64754e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Apr 2025 20:29:53 -0700 Subject: [PATCH 249/260] Bump the kubernetes group with 6 updates (#754) Bumps the kubernetes group with 6 updates: | Package | From | To | | --- | --- | --- | | [k8s.io/api](https://github.com/kubernetes/api) | `0.32.3` | `0.32.4` | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.32.3` | `0.32.4` | | [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `0.32.3` | `0.32.4` | | [k8s.io/client-go](https://github.com/kubernetes/client-go) | `0.32.3` | `0.32.4` | | [k8s.io/code-generator](https://github.com/kubernetes/code-generator) | `0.32.3` | `0.32.4` | | [k8s.io/component-base](https://github.com/kubernetes/component-base) | `0.32.3` | `0.32.4` | Updates `k8s.io/api` from 0.32.3 to 0.32.4 - [Commits](https://github.com/kubernetes/api/compare/v0.32.3...v0.32.4) Updates `k8s.io/apiextensions-apiserver` from 0.32.3 to 0.32.4 - [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases) - [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.32.3...v0.32.4) Updates `k8s.io/apimachinery` from 0.32.3 to 0.32.4 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.32.3...v0.32.4) Updates `k8s.io/client-go` from 0.32.3 to 0.32.4 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.32.3...v0.32.4) Updates `k8s.io/code-generator` from 0.32.3 to 0.32.4 - [Commits](https://github.com/kubernetes/code-generator/compare/v0.32.3...v0.32.4) Updates `k8s.io/component-base` from 0.32.3 to 0.32.4 - [Commits](https://github.com/kubernetes/component-base/compare/v0.32.3...v0.32.4) --- updated-dependencies: - dependency-name: k8s.io/api dependency-version: 0.32.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apiextensions-apiserver dependency-version: 0.32.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apimachinery dependency-version: 0.32.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/client-go dependency-version: 0.32.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/code-generator dependency-version: 0.32.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/component-base dependency-version: 0.32.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index fcfb60af..076bdf4b 100644 --- a/go.mod +++ b/go.mod @@ -17,12 +17,12 @@ require ( go.uber.org/zap v1.27.0 google.golang.org/grpc v1.71.1 google.golang.org/protobuf v1.36.6 - k8s.io/api v0.32.3 - k8s.io/apiextensions-apiserver v0.32.3 - k8s.io/apimachinery v0.32.3 - k8s.io/client-go v0.32.3 - k8s.io/code-generator v0.32.3 - k8s.io/component-base v0.32.3 + k8s.io/api v0.32.4 + k8s.io/apiextensions-apiserver v0.32.4 + k8s.io/apimachinery v0.32.4 + k8s.io/client-go v0.32.4 + k8s.io/code-generator v0.32.4 + k8s.io/component-base v0.32.4 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.4 sigs.k8s.io/structured-merge-diff/v4 v4.7.0 @@ -123,7 +123,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.32.3 // indirect + k8s.io/apiserver v0.32.4 // indirect k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect diff --git a/go.sum b/go.sum index b2c05a61..0258fc7a 100644 --- a/go.sum +++ b/go.sum @@ -300,20 +300,20 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= -k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/apiserver v0.32.3 h1:kOw2KBuHOA+wetX1MkmrxgBr648ksz653j26ESuWNY8= -k8s.io/apiserver v0.32.3/go.mod h1:q1x9B8E/WzShF49wh3ADOh6muSfpmFL0I2t+TG0Zdgc= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= -k8s.io/code-generator v0.32.3 h1:31p2TVzC9+hVdSkAFruAk3JY+iSfzrJ83Qij1yZutyw= -k8s.io/code-generator v0.32.3/go.mod h1:+mbiYID5NLsBuqxjQTygKM/DAdKpAjvBzrJd64NU1G8= -k8s.io/component-base v0.32.3 h1:98WJvvMs3QZ2LYHBzvltFSeJjEx7t5+8s71P7M74u8k= -k8s.io/component-base v0.32.3/go.mod h1:LWi9cR+yPAv7cu2X9rZanTiFKB2kHA+JjmhkKjCZRpI= +k8s.io/api v0.32.4 h1:kw8Y/G8E7EpNy7gjB8gJZl3KJkNz8HM2YHrZPtAZsF4= +k8s.io/api v0.32.4/go.mod h1:5MYFvLvweRhyKylM3Es/6uh/5hGp0dg82vP34KifX4g= +k8s.io/apiextensions-apiserver v0.32.4 h1:IA+CoR63UDOijR/vEpow6wQnX4V6iVpzazJBskHrpHE= +k8s.io/apiextensions-apiserver v0.32.4/go.mod h1:Y06XO/b92H8ymOdG1HlA1submf7gIhbEDc3RjriqZOs= +k8s.io/apimachinery v0.32.4 h1:8EEksaxA7nd7xWJkkwLDN4SvWS5ot9g6Z/VZb3ju25I= +k8s.io/apimachinery v0.32.4/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apiserver v0.32.4 h1:Yf7sd/y+GOQKH1Qf6wUeayZrYXe2SKZ17Bcq7VQM5HQ= +k8s.io/apiserver v0.32.4/go.mod h1:JFUMNtE2M5yqLZpIsgCb06SkVSW1YcxW1oyLSTfjXR8= +k8s.io/client-go v0.32.4 h1:zaGJS7xoYOYumoWIFXlcVrsiYioRPrXGO7dBfVC5R6M= +k8s.io/client-go v0.32.4/go.mod h1:k0jftcyYnEtwlFW92xC7MTtFv5BNcZBr+zn9jPlT9Ic= +k8s.io/code-generator v0.32.4 h1:d4dm/43RD6xhPBX22JgJw9JUpwTKzVR6tAxJD7pz83o= +k8s.io/code-generator v0.32.4/go.mod h1:R0bKdIg1smtvsKvj9q7SxTeKq5X9ko6PuICCGt4yqxg= +k8s.io/component-base v0.32.4 h1:HuF+2JVLbFS5GODLIfPCb1Td6b+G2HszJoArcWOSr5I= +k8s.io/component-base v0.32.4/go.mod h1:10KloJEYw1keU/Xmjfy9TKJqUq7J2mYdiD1VDXoco4o= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkvvIeH8UqffFJDl0bz4= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= From 28c7484cc93eb5b6110ce50c4467b390a564b05c Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 29 Apr 2025 18:36:00 +0300 Subject: [PATCH 250/260] extract pod representation from backend/metrics to backend (#751) Signed-off-by: Nir Rozenbaum --- pkg/epp/backend/metrics/fake.go | 7 +-- pkg/epp/backend/metrics/metrics.go | 12 ++--- pkg/epp/backend/metrics/metrics_test.go | 3 +- pkg/epp/backend/metrics/pod_metrics.go | 11 ++--- pkg/epp/backend/metrics/types.go | 29 +----------- pkg/epp/backend/pod.go | 45 +++++++++++++++++++ pkg/epp/handlers/server.go | 4 +- .../scheduling/plugins/filter/filter_test.go | 5 ++- pkg/epp/scheduling/scheduler_test.go | 43 +++++++++--------- pkg/epp/scheduling/types/types.go | 7 +-- test/integration/epp/hermetic_test.go | 29 ++++++------ 11 files changed, 108 insertions(+), 87 deletions(-) create mode 100644 pkg/epp/backend/pod.go diff --git a/pkg/epp/backend/metrics/fake.go b/pkg/epp/backend/metrics/fake.go index ec97c6de..58d05026 100644 --- a/pkg/epp/backend/metrics/fake.go +++ b/pkg/epp/backend/metrics/fake.go @@ -24,12 +24,13 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) // FakePodMetrics is an implementation of PodMetrics that doesn't run the async refresh loop. type FakePodMetrics struct { - Pod *Pod + Pod *backend.Pod Metrics *Metrics } @@ -37,7 +38,7 @@ func (fpm *FakePodMetrics) String() string { return fmt.Sprintf("Pod: %v; Metrics: %v", fpm.GetPod(), fpm.GetMetrics()) } -func (fpm *FakePodMetrics) GetPod() *Pod { +func (fpm *FakePodMetrics) GetPod() *backend.Pod { return fpm.Pod } func (fpm *FakePodMetrics) GetMetrics() *Metrics { @@ -55,7 +56,7 @@ type FakePodMetricsClient struct { Res map[types.NamespacedName]*Metrics } -func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod *Pod, existing *Metrics, port int32) (*Metrics, error) { +func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod *backend.Pod, existing *Metrics, port int32) (*Metrics, error) { f.errMu.RLock() err, ok := f.Err[pod.NamespacedName] f.errMu.RUnlock() diff --git a/pkg/epp/backend/metrics/metrics.go b/pkg/epp/backend/metrics/metrics.go index 96814b4b..4cf56179 100644 --- a/pkg/epp/backend/metrics/metrics.go +++ b/pkg/epp/backend/metrics/metrics.go @@ -26,6 +26,7 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" "go.uber.org/multierr" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" ) const ( @@ -39,15 +40,8 @@ type PodMetricsClientImpl struct { MetricMapping *MetricMapping } -// FetchMetrics fetches metrics from a given pod, clones the existing metrics object and returns an -// updated one. -func (p *PodMetricsClientImpl) FetchMetrics( - ctx context.Context, - pod *Pod, - existing *Metrics, - port int32, -) (*Metrics, error) { - +// FetchMetrics fetches metrics from a given pod, clones the existing metrics object and returns an updated one. +func (p *PodMetricsClientImpl) FetchMetrics(ctx context.Context, pod *backend.Pod, existing *Metrics, port int32) (*Metrics, error) { // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. url := "http://" + pod.Address + ":" + strconv.Itoa(int(port)) + "/metrics" diff --git a/pkg/epp/backend/metrics/metrics_test.go b/pkg/epp/backend/metrics/metrics_test.go index e3b45b94..53127010 100644 --- a/pkg/epp/backend/metrics/metrics_test.go +++ b/pkg/epp/backend/metrics/metrics_test.go @@ -30,6 +30,7 @@ import ( "google.golang.org/protobuf/proto" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -486,7 +487,7 @@ func TestPromToPodMetrics(t *testing.T) { // there's no server running on the specified port. func TestFetchMetrics(t *testing.T) { ctx := logutil.NewTestLoggerIntoContext(context.Background()) - pod := &Pod{ + pod := &backend.Pod{ Address: "127.0.0.1", NamespacedName: types.NamespacedName{ Namespace: "test", diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index 7339389a..bdeb28ba 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -35,7 +36,7 @@ const ( ) type podMetrics struct { - pod atomic.Pointer[Pod] + pod atomic.Pointer[backend.Pod] metrics atomic.Pointer[Metrics] pmc PodMetricsClient ds Datastore @@ -48,14 +49,14 @@ type podMetrics struct { } type PodMetricsClient interface { - FetchMetrics(ctx context.Context, pod *Pod, existing *Metrics, port int32) (*Metrics, error) + FetchMetrics(ctx context.Context, pod *backend.Pod, existing *Metrics, port int32) (*Metrics, error) } func (pm *podMetrics) String() string { return fmt.Sprintf("Pod: %v; Metrics: %v", pm.GetPod(), pm.GetMetrics()) } -func (pm *podMetrics) GetPod() *Pod { +func (pm *podMetrics) GetPod() *backend.Pod { return pm.pod.Load() } @@ -67,8 +68,8 @@ func (pm *podMetrics) UpdatePod(in *corev1.Pod) { pm.pod.Store(toInternalPod(in)) } -func toInternalPod(in *corev1.Pod) *Pod { - return &Pod{ +func toInternalPod(in *corev1.Pod) *backend.Pod { + return &backend.Pod{ NamespacedName: types.NamespacedName{ Name: in.Name, Namespace: in.Namespace, diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index 156ac3ed..4932e3ac 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -24,8 +24,8 @@ import ( "time" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" ) func NewPodMetricsFactory(pmc PodMetricsClient, refreshMetricsInterval time.Duration) *PodMetricsFactory { @@ -58,38 +58,13 @@ func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1. } type PodMetrics interface { - GetPod() *Pod + GetPod() *backend.Pod GetMetrics() *Metrics UpdatePod(*corev1.Pod) StopRefreshLoop() String() string } -type Pod struct { - NamespacedName types.NamespacedName - Address string -} - -func (p *Pod) String() string { - if p == nil { - return "" - } - return fmt.Sprintf("%+v", *p) -} - -func (p *Pod) Clone() *Pod { - if p == nil { - return nil - } - return &Pod{ - NamespacedName: types.NamespacedName{ - Name: p.NamespacedName.Name, - Namespace: p.NamespacedName.Namespace, - }, - Address: p.Address, - } -} - type Metrics struct { // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. ActiveModels map[string]int diff --git a/pkg/epp/backend/pod.go b/pkg/epp/backend/pod.go new file mode 100644 index 00000000..a63a0a83 --- /dev/null +++ b/pkg/epp/backend/pod.go @@ -0,0 +1,45 @@ +/* +Copyright 2025 The Kubernetes 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 backend + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/types" +) + +type Pod struct { + NamespacedName types.NamespacedName + Address string +} + +func (p *Pod) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("%+v", *p) +} + +func (p *Pod) Clone() *Pod { + if p == nil { + return nil + } + return &Pod{ + NamespacedName: types.NamespacedName{ + Name: p.NamespacedName.Name, + Namespace: p.NamespacedName.Namespace, + }, + Address: p.Address, + } +} diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 5e23c7a0..630baef3 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -34,7 +34,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" @@ -447,7 +447,7 @@ func RandomWeightedDraw(logger logr.Logger, model *v1alpha2.InferenceModel, seed return "" } -func GetRandomPod(ds datastore.Datastore) *backendmetrics.Pod { +func GetRandomPod(ds datastore.Datastore) *backend.Pod { pods := ds.PodGetAll() if len(pods) == 0 { return nil diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index a06ec3ca..2354c3ef 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -22,6 +22,7 @@ import ( "github.com/google/go-cmp/cmp" k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" @@ -227,7 +228,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { // Test setup: One affinity pod and one available pod pods := []types.Pod{ &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "affinity-pod"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "affinity-pod"}}, Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{ @@ -236,7 +237,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, }, &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "available-pod"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "available-pod"}}, Metrics: &backendmetrics.Metrics{ MaxActiveModels: 2, ActiveModels: map[string]int{}, diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 311f44e9..b44c7ac2 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -22,6 +22,7 @@ import ( "github.com/google/go-cmp/cmp" k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" @@ -57,7 +58,7 @@ func TestSchedule(t *testing.T) { // model being active, and has low KV cache. input: []*backendmetrics.FakePodMetrics{ { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -69,7 +70,7 @@ func TestSchedule(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -81,7 +82,7 @@ func TestSchedule(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, @@ -95,7 +96,7 @@ func TestSchedule(t *testing.T) { wantRes: &types.Result{ TargetPod: &types.ScoredPod{ Pod: &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -120,7 +121,7 @@ func TestSchedule(t *testing.T) { // pod1 will be picked because it has capacity for the sheddable request. input: []*backendmetrics.FakePodMetrics{ { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -132,7 +133,7 @@ func TestSchedule(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -144,7 +145,7 @@ func TestSchedule(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, @@ -158,7 +159,7 @@ func TestSchedule(t *testing.T) { wantRes: &types.Result{ TargetPod: &types.ScoredPod{ Pod: &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -184,7 +185,7 @@ func TestSchedule(t *testing.T) { // dropped. input: []*backendmetrics.FakePodMetrics{ { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, @@ -196,7 +197,7 @@ func TestSchedule(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.85, @@ -208,7 +209,7 @@ func TestSchedule(t *testing.T) { }, }, { - Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.85, @@ -282,9 +283,9 @@ func TestSchedulePlugins(t *testing.T) { postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, }, wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, targetPodScore: 1.1, @@ -304,9 +305,9 @@ func TestSchedulePlugins(t *testing.T) { postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, }, wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, targetPodScore: 50, @@ -326,9 +327,9 @@ func TestSchedulePlugins(t *testing.T) { postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backendmetrics.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, }, numPodsToScore: 0, err: true, // no available pods to server after filter all @@ -369,7 +370,7 @@ func TestSchedulePlugins(t *testing.T) { // Validate output wantPod := &types.PodMetrics{ - Pod: &backendmetrics.Pod{NamespacedName: test.wantTargetPod}, + Pod: &backend.Pod{NamespacedName: test.wantTargetPod}, } wantRes := &types.Result{TargetPod: wantPod} if diff := cmp.Diff(wantRes, got); diff != "" { diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index 5198515b..4f69fae0 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -22,6 +22,7 @@ import ( "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" ) @@ -41,7 +42,7 @@ func (r *LLMRequest) String() string { } type Pod interface { - GetPod() *backendmetrics.Pod + GetPod() *backend.Pod GetMetrics() *backendmetrics.Metrics String() string } @@ -66,7 +67,7 @@ func (pm *PodMetrics) String() string { return fmt.Sprintf("%+v", *pm) } -func (pm *PodMetrics) GetPod() *backendmetrics.Pod { +func (pm *PodMetrics) GetPod() *backend.Pod { return pm.Pod } @@ -75,7 +76,7 @@ func (pm *PodMetrics) GetMetrics() *backendmetrics.Metrics { } type PodMetrics struct { - *backendmetrics.Pod + *backend.Pod *backendmetrics.Metrics } diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 79b619fd..35361329 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -61,6 +61,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" @@ -96,7 +97,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { tests := []struct { name string requests []*extProcPb.ProcessingRequest - pods map[backendmetrics.Pod]*backendmetrics.Metrics + pods map[backend.Pod]*backendmetrics.Metrics wantResponses []*extProcPb.ProcessingResponse wantMetrics map[string]string wantErr bool @@ -107,7 +108,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { name: "select lower queue and kv cache, no active lora", requests: integrationutils.GenerateStreamedRequestSet(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 3, KVCacheUsagePercent: 0.2, @@ -182,7 +183,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { requests: integrationutils.GenerateStreamedRequestSet(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -267,7 +268,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, @@ -350,7 +351,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { requests: integrationutils.GenerateStreamedRequestSet(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 6, KVCacheUsagePercent: 0.2, @@ -398,7 +399,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { name: "noncritical, but one server has capacity, do not shed", requests: integrationutils.GenerateStreamedRequestSet(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -509,7 +510,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -620,7 +621,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -732,7 +733,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -831,7 +832,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -1179,7 +1180,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { DynamicMetadata: makeMetadata("192.168.1.1:8000"), }, }, - pods: map[backendmetrics.Pod]*backendmetrics.Metrics{ + pods: map[backend.Pod]*backendmetrics.Metrics{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -1225,7 +1226,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +func setUpHermeticServer(t *testing.T, podAndMetrics map[backend.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { // Reconfigure the TestPodMetricsClient. res := map[types.NamespacedName]*backendmetrics.Metrics{} for pod, metrics := range podAndMetrics { @@ -1303,8 +1304,8 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac } } -func fakePod(index int) backendmetrics.Pod { - return backendmetrics.Pod{ +func fakePod(index int) backend.Pod { + return backend.Pod{ NamespacedName: types.NamespacedName{Name: fmt.Sprintf("pod-%v", index), Namespace: "default"}, Address: fmt.Sprintf("192.168.1.%d", index+1), } From cb0524ba0a7f0c6cdad2afacdcc2fd63f9ca1cb4 Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Tue, 29 Apr 2025 23:51:55 +0800 Subject: [PATCH 251/260] Request for adding Alibaba Cloud Container Service for Kubernetes (ACK) into implementations (#748) * add ack gie to implementations Signed-off-by: Hang Yin * fix documentation links * supply a github issue to track GIE support of ACK --------- Signed-off-by: Hang Yin --- site-src/implementations/gateways.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/site-src/implementations/gateways.md b/site-src/implementations/gateways.md index b44dca6f..950c0833 100644 --- a/site-src/implementations/gateways.md +++ b/site-src/implementations/gateways.md @@ -6,11 +6,13 @@ This project has several implementations that are planned or in progress: * [Kgateway][2] * [Google Kubernetes Engine][3] * [Istio][4] +* [Alibaba Cloud Container Service for Kubernetes][5] [1]:#envoy-gateway [2]:#kgateway [3]:#google-kubernetes-engine [4]:#istio +[5]:#alibaba-cloud-container-service-for-kubernetes ## Envoy AI Gateway @@ -65,3 +67,22 @@ For service mesh users, Istio also fully supports east-west (including [GAMMA](h Gateway API Inference Extension support is being tracked by this [GitHub Issue](https://github.com/istio/istio/issues/55768). + +## Alibaba Cloud Container Service for Kubernetes + +[Alibaba Cloud Container Service for Kubernetes (ACK)][ack] is a managed Kubernetes platform +offered by Alibaba Cloud. The implementation of the Gateway API in ACK is through the +[ACK Gateway with Inference Extension][ack-gie] component, which introduces model-aware, +GPU-efficient load balancing for AI workloads beyond basic HTTP routing. + +The ACK Gateway with Inference Extension implements the Gateway API Inference Extension +and provides optimized routing for serving generative AI workloads, +including weighted traffic splitting, mirroring, advanced routing, etc. +See the docs for the [usage][ack-gie-usage]. + +Progress towards supporting Gateway API Inference Extension is being tracked +by [this Issue](https://github.com/AliyunContainerService/ack-gateway-api/issues/1). + +[ack]:https://www.alibabacloud.com/help/en/ack +[ack-gie]:https://www.alibabacloud.com/help/en/ack/product-overview/ack-gateway-with-inference-extension +[ack-gie-usage]:https://www.alibabacloud.com/help/en/ack/ack-managed-and-ack-dedicated/user-guide/intelligent-routing-and-traffic-management-with-ack-gateway-inference-extension \ No newline at end of file From ea75ca135364e136cee8ab7f310930270a759e9c Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 29 Apr 2025 18:52:02 +0300 Subject: [PATCH 252/260] fixed error message in scheduler when no pods are available (#759) Signed-off-by: Nir Rozenbaum --- pkg/epp/scheduling/scheduler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 04d24ea2..1a1d67b5 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -110,7 +110,7 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types pods := s.runFilterPlugins(sCtx) if len(pods) == 0 { - return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: "failed to find a target pod"} + return nil, errutil.Error{Code: errutil.Internal, Msg: "no pods available for the given request"} } // if we got here, there is at least one pod to score weightedScorePerPod := s.runScorerPlugins(sCtx, pods) From ef3d01a07e3b1598e8929ffef46144d91b49eb77 Mon Sep 17 00:00:00 2001 From: sina chavoshi Date: Tue, 29 Apr 2025 09:21:55 -0700 Subject: [PATCH 253/260] feat: Initial setup for conformance test suite (#720) * feat: Initial setup for conformance test suite * fix missing go.sum entry * Fix the API version and basic Inferencepool-basic-accepted Yaml definition. * exclude conformance tests from github acceptance test run. * Add support for multiple profiles, remove release channel and update version to use semver. * Adding another layer to the report hierarchy for category of conformance (gateway, epp, model server). * Add trailing new line to yaml files. * switch to use InferencePoolMustHaveCondition from /conformance/utils/kubernetes * remove extra godoc comments * Remove references to ExtensionChannel from reports readme * format readme * remove the service for the conformance backend. * update the namespace and EEP names to match the manifest. * Update PR based on review feedback including, change dir name to lower case, remove unused manifest, remove NamespaceLabels and NamespaceAnnotations * add a comment to clarify use of echo server --- Makefile | 2 +- conformance/conformance.go | 230 ++++++++++++++++++ conformance/conformance_test.go | 29 +++ conformance/embed.go | 25 ++ conformance/reports/README.md | 93 +++++++ .../resources/manifests/manifests.yaml | 49 ++++ .../tests/basic/inferencepool_accepted.go | 60 +++++ .../tests/basic/inferencepool_accepted.yaml | 27 ++ conformance/tests/main.go | 35 +++ conformance/utils/assertions.go | 25 ++ conformance/utils/kubernetes/helpers.go | 49 ++++ conformance/utils/traffic/traffic.go | 22 ++ go.mod | 15 +- go.sum | 41 ++-- 14 files changed, 671 insertions(+), 31 deletions(-) create mode 100644 conformance/conformance.go create mode 100644 conformance/conformance_test.go create mode 100644 conformance/embed.go create mode 100644 conformance/reports/README.md create mode 100644 conformance/resources/manifests/manifests.yaml create mode 100644 conformance/tests/basic/inferencepool_accepted.go create mode 100644 conformance/tests/basic/inferencepool_accepted.yaml create mode 100644 conformance/tests/main.go create mode 100644 conformance/utils/assertions.go create mode 100644 conformance/utils/kubernetes/helpers.go create mode 100644 conformance/utils/traffic/traffic.go diff --git a/Makefile b/Makefile index 563e0ce9..4826a029 100644 --- a/Makefile +++ b/Makefile @@ -121,7 +121,7 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate fmt vet envtest image-build ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -race -coverprofile cover.out + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e | grep -v /conformance) -race -coverprofile cover.out .PHONY: test-unit test-unit: ## Run unit tests. diff --git a/conformance/conformance.go b/conformance/conformance.go new file mode 100644 index 00000000..20d80fde --- /dev/null +++ b/conformance/conformance.go @@ -0,0 +1,230 @@ +/* +Copyright 2025 The Kubernetes 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 conformance contains the core setup and execution logic +// for the Gateway API Inference Extension conformance test suite. +package conformance + +import ( + "fmt" + "io/fs" + "os" + "testing" + + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + clientset "k8s.io/client-go/kubernetes" + + // Import runtime package for scheme creation + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/yaml" + + // Import necessary types and utilities from the core Gateway API conformance suite. + // Assumes sigs.k8s.io/gateway-api is a dependency in the go.mod. + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" // Import core Gateway API types + confapis "sigs.k8s.io/gateway-api/conformance/apis/v1" // Report struct definition + confconfig "sigs.k8s.io/gateway-api/conformance/utils/config" + confflags "sigs.k8s.io/gateway-api/conformance/utils/flags" + confsuite "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" // Using core features definitions if applicable + + // Import the test definitions package to access the ConformanceTests slice + "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" + + // Import test packages using blank identifier + // This triggers the init() functions in these packages, which register the tests + // by appending them to the tests.ConformanceTests slice. + _ "sigs.k8s.io/gateway-api-inference-extension/conformance/tests/basic" + // TODO: Add blank imports for other test categories as they are created. + // _ "sigs.k8s.io/gateway-api-inference-extension/conformance/tests/model_routing" + + // Import the Inference Extension API types + inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" +) + +// GatewayLayerProfileName defines the name for the conformance profile that tests +// the Gateway API layer aspects of the Inference Extension (e.g., InferencePool, InferenceModel CRDs). +// Future profiles will cover EPP and ModelServer layers. +const GatewayLayerProfileName confsuite.ConformanceProfileName = "Gateway" + +var InferenceCoreFeatures = sets.New[features.FeatureName]() // Placeholder - Populate with actual features specific to this profile or manage features per profile + +// GatewayLayerProfile defines the conformance profile for the Gateway API layer +// of the Inference Extension. +// In future iterations, we will add constants and ConformanceProfile structs for +// EPPProfileName ("EPP") and ModelServerProfileName ("ModelServer") +// to cover their respective conformance layers. +var GatewayLayerProfile = confsuite.ConformanceProfile{ + Name: GatewayLayerProfileName, + CoreFeatures: InferenceCoreFeatures, +} + +// DefaultOptions parses command line flags and sets up the suite options. +// Adapted from the core Gateway API conformance suite. +func DefaultOptions(t *testing.T) confsuite.ConformanceOptions { + t.Helper() + + cfg, err := config.GetConfig() + require.NoError(t, err, "error loading Kubernetes config") + + // Initialize client options. The scheme must include Gateway API types + // and the Inference Extension types. + clientOptions := client.Options{} + scheme := clientOptions.Scheme + if scheme == nil { + // If default options don't provide a scheme, create one using runtime.NewScheme(). + scheme = runtime.NewScheme() + clientOptions.Scheme = scheme + } + + // Register necessary API Types + require.NoError(t, gatewayv1.Install(scheme)) // Add core Gateway API types + // Add the Inference Extension API types to the scheme using the correct import alias + require.NoError(t, inferencev1alpha2.Install(scheme)) + require.NoError(t, apiextensionsv1.AddToScheme(scheme)) // Needed for CRD checks + + // Create the Kubernetes clients + c, err := client.New(cfg, clientOptions) + require.NoError(t, err, "error initializing Kubernetes client") + cs, err := clientset.NewForConfig(cfg) + require.NoError(t, err, "error initializing Kubernetes clientset") + + exemptFeatures := confsuite.ParseSupportedFeatures(*confflags.ExemptFeatures) + skipTests := confsuite.ParseSkipTests(*confflags.SkipTests) + // Initially, run the GatewayLayerProfile. This will expand as other profiles + // (EPP, ModelServer) are added and can be selected via flags in future iterations. + conformanceProfiles := sets.New(GatewayLayerProfileName) + + // Implementation details from flags + implementation := confsuite.ParseImplementation( + *confflags.ImplementationOrganization, + *confflags.ImplementationProject, + *confflags.ImplementationURL, + *confflags.ImplementationVersion, + *confflags.ImplementationContact, + ) + + // Inference Extension Specific Report Fields + inferenceExtensionVersion := "v0.3.0" + _ = inferenceExtensionVersion // Avoid unused variable error until implemented + + // Create ConformanceOptions + opts := confsuite.ConformanceOptions{ + Client: c, + Clientset: cs, + RestConfig: cfg, + GatewayClassName: *confflags.GatewayClassName, + Debug: *confflags.ShowDebug, + CleanupBaseResources: *confflags.CleanupBaseResources, + SupportedFeatures: sets.New[features.FeatureName](), // Initialize empty, will be populated below + TimeoutConfig: confconfig.DefaultTimeoutConfig(), + SkipTests: skipTests, + ExemptFeatures: exemptFeatures, + RunTest: *confflags.RunTest, + Mode: *confflags.Mode, + Implementation: implementation, + ConformanceProfiles: conformanceProfiles, + ManifestFS: []fs.FS{&Manifests}, // Assumes embed.go defines `Manifests` + ReportOutputPath: *confflags.ReportOutput, + SkipProvisionalTests: *confflags.SkipProvisionalTests, + // TODO: Add the inference extension specific fields to ConformanceOptions struct if needed, + // or handle them during report generation. + // GatewayAPIInferenceExtensionChannel: inferenceExtensionChannel, + // GatewayAPIInferenceExtensionVersion: inferenceExtensionVersion, + } + + // Populate SupportedFeatures based on the GatewayLayerProfile. + // Since all features are mandatory for this profile, add all defined core features. + if opts.ConformanceProfiles.Has(GatewayLayerProfileName) { + for feature := range GatewayLayerProfile.CoreFeatures { + opts.SupportedFeatures.Insert(feature) + } + } + + // Remove any features explicitly exempted via flags. + for feature := range opts.ExemptFeatures { + opts.SupportedFeatures.Delete(feature) + } + + return opts +} + +// RunConformance runs the Inference Extension conformance tests using default options. +func RunConformance(t *testing.T) { + RunConformanceWithOptions(t, DefaultOptions(t)) +} + +// RunConformanceWithOptions runs the Inference Extension conformance tests with specific options. +func RunConformanceWithOptions(t *testing.T, opts confsuite.ConformanceOptions) { + t.Logf("Running Inference Extension conformance tests with GatewayClass %s", opts.GatewayClassName) + + // Register the GatewayLayerProfile with the suite runner. + // In the future, other profiles (EPP, ModelServer) will also be registered here, + // and the suite runner will execute tests based on the selected profiles. + confsuite.RegisterConformanceProfile(GatewayLayerProfile) + + // Initialize the test suite. + cSuite, err := confsuite.NewConformanceTestSuite(opts) + require.NoError(t, err, "error initializing conformance suite") + + t.Log("Setting up Inference Extension conformance tests") + // Setup requires the list of tests, which is populated by the init() functions + // triggered by the blank imports at the top of this file. + cSuite.Setup(t, tests.ConformanceTests) + + t.Log("Running Inference Extension conformance tests") + // Run the tests. + err = cSuite.Run(t, tests.ConformanceTests) + require.NoError(t, err, "error running conformance tests") + + // Generate and write the report if requested. + if opts.ReportOutputPath != "" { + t.Log("Generating Inference Extension conformance report") + report, err := cSuite.Report() // Use the existing report generation logic. + require.NoError(t, err, "error generating conformance report") + + // TODO: Modify the report struct here if channel, version need to be modified. + // Example (requires adding fields to confapis.ConformanceReport): + // report.GatewayAPIInferenceExtensionChannel = opts.GatewayAPIInferenceExtensionChannel + // report.GatewayAPIInferenceExtensionVersion = opts.GatewayAPIInferenceExtensionVersion + + err = writeReport(t.Logf, *report, opts.ReportOutputPath) + require.NoError(t, err, "error writing conformance report") + } +} + +// writeReport writes the generated conformance report to the specified output file or logs it. +// Adapted from the core Gateway API suite. +func writeReport(logf func(string, ...any), report confapis.ConformanceReport, output string) error { + rawReport, err := yaml.Marshal(report) + if err != nil { + return fmt.Errorf("error marshaling report: %w", err) + } + + if output != "" { + if err = os.WriteFile(output, rawReport, 0o600); err != nil { + return fmt.Errorf("error writing report file %s: %w", output, err) + } + logf("Conformance report written to %s", output) + } else { + // Log the report YAML to stdout if no output file is specified. + logf("Conformance report:\n%s", string(rawReport)) + } + return nil +} diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go new file mode 100644 index 00000000..de82d5ec --- /dev/null +++ b/conformance/conformance_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 The Kubernetes 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 conformance + +import ( + "testing" +) + +// TestConformance is the top-level function that runs the conformance tests. +// It calls the RunConformance function which sets up the suite and executes +// the registered tests. +func TestConformance(t *testing.T) { + // RunConformance is defined in conformance.go + RunConformance(t) +} diff --git a/conformance/embed.go b/conformance/embed.go new file mode 100644 index 00000000..f7fa64c9 --- /dev/null +++ b/conformance/embed.go @@ -0,0 +1,25 @@ +/* +Copyright 2025 The Kubernetes 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 conformance + +import "embed" + +// Manifests embeds the contents of the conformance/resources directory making +// the YAML files within them available to the test suite at runtime. +// +//go:embed resources/* tests/* +var Manifests embed.FS diff --git a/conformance/reports/README.md b/conformance/reports/README.md new file mode 100644 index 00000000..81652b1c --- /dev/null +++ b/conformance/reports/README.md @@ -0,0 +1,93 @@ +# Conformance Reports for Gateway API Inference Extension + +This directory stores conformance reports submitted by various implementations of the Gateway API Inference Extension. This structure closely follows the [kubernetes-sigs/gateway-api/conformance/reports](https://github.com/kubernetes-sigs/gateway-api/blob/main/conformance/reports/README.md). + +## How this folder is structured + +This folder stores conformance reports organized first by the version of the Gateway API Inference Extension specification they were tested against, and then by the specific conformance profile (e.g., Gateway, EPP, Model Server): + +|-- conformance/reports +| |-- v0.3.0 # Example extension version +| | |-- gateway # Conformance profile/category +| | | |-- my-inference-gateway +| | | | |-- README.md +| | | | |-- experimental-v1.2.3-default-gateway-report.yaml # Example report file +| | | |-- another-implementation +| | | | |-- README.md +| | | | |-- ... +| | |-- epp # Future conformance profile/category +| | | |-- my-epp-implementation +| | | | |-- ... +| | |-- model-server # Future conformance profile/category +| | | |-- ... +| |-- v0.4.0 # Future extension version +| | |-- ... + +## Implementation Submissions + +Each implementation conformant with a specific profile of a specific version of the Gateway API Inference Extension should have its own folder within the corresponding version and profile directory (e.g., `/conformance/reports/v0.3.0/Gateway/my-implementation/`). + +The implementation is the owner of its folder and is responsible for: + +1. Uploading one or more conformance reports (YAML files). +2. Maintaining a mandatory `README.md` file within their folder, structured as follows: + + # My Inference Gateway Implementation (Gateway Profile Conformance) + + General information about the My/Implementation project. + + ## Table of Contents + +| Extension Version Tested | Profile Tested | Implementation Version | Mode | Report | +|--------------------------|----------------|------------------------|---------|----------------------------------------------------------------------------| +| v0.3.0 | Gateway | v1.2.3 | default | [v1.2.3 Gateway report](./experimental-v1.2.3-default-gateway-report.yaml) | +| ... | ... | ... | ... | ... | + + ## Reproduce + + Instructions on how to reproduce the claimed report(s). + +### Table of Contents (within Implementation README) + +The table of contents within an implementation's `README.md` should contain one row for each submitted report and include the following columns: + +* **Extension Version Tested**: The version of the Gateway API Inference Extension specification tested against (e.g., `v0.3.0`). Must correspond to the `gatewayAPIInferenceExtensionVersion` field in the report. +* **Profile Tested**: The specific conformance profile tested (e.g., `Gateway`, `EPP`, `ModelServer`). Must correspond to the `name` of the profile in the `profiles` list within the report. +* **Implementation Version**: A link to the GitHub/website page for the specific release/commit of the implementation tested. The version value MUST correspond to the `implementation.version` field in the report. +* **Mode**: The operating mode of the implementation used for the test run (default is `default`). Must correspond to the `mode` field in the report. If a mode other than `default` is used, the "Reproduce" section must explain how to configure it. +* **Report**: A link to the corresponding report YAML file. Reports MUST be named according to the pattern: `---report.yaml` (e.g., `experimental-v1.2.3-default-gateway-report.yaml`). + +### Reproduce Section (within Implementation README) + +This section MUST exist and contain the manual or automatic steps required to reproduce the results claimed by the uploaded conformance reports for that specific implementation. If reproduction steps differ significantly between implementation versions, use sub-sections. + +## Report Files + +Conformance reports MUST be uploaded exactly as generated by the official Gateway API Inference Extension conformance test suite, without any modifications. The "Reproduce" section allows for verification of the submitted report against a fresh run. + +### Report Rules + +To be accepted, submitted conformance reports must comply with the following rules: + +1. **Implementation Details:** All fields within the `implementation` block must have meaningful values: + * `organization`: The entity maintaining the implementation (company, open source org, individual). + * `project`: The name of the implementation project, unique within the organization. + * `url`: A valid URL for the project (e.g., GitHub repository, product page). + * `version`: A specific, reproducible snapshot of the implementation (e.g., tag, commit hash, release version). Branch names are not acceptable. + * `contact`: A list of contact points (GitHub handles like `@maintainer`, team handles like `@org/team`, email addresses, or support URLs like an issue tracker). +2. **Inference Extension Versioning:** The report MUST include: + * `gatewayAPIInferenceExtensionVersion`: The specific version of the Gateway API Inference Extension specification tested against (e.g., `v0.3.0`). +3. **Mode:** The `mode` field indicates the implementation's operating mode during the test run. +4. **Test Profile & Result:** + * The report MUST contain exactly one profile result under the `profiles` list for the specific conformance category being submitted (e.g., a report for "Gateway" conformance should only contain the "Gateway" profile result). + * The profile's `name` MUST match the conformance category (e.g., `Gateway`, `EPP`, `ModelServer`). + * The profile's `result` field MUST be `success`. A `success` result indicates that **all** tests defined within the Gateway API Inference Extension conformance suite for that specific profile and version passed. + +## Submission Process + +Conformance reports demonstrating a `success` result for a specific profile (e.g., `Gateway`) should be submitted via Pull Request directly to this repository (`kubernetes-sigs/gateway-api-inference-extension`). + +1. Create a new folder structure under `/conformance/reports///` named after your implementation (e.g., `/conformance/reports/v0.3.0/Gateway/my-implementation/`). +2. Add your implementation's `README.md` to this folder, following the structure described above. +3. Add your generated conformance report YAML file(s) to this folder, ensuring they follow the naming convention `---report.yaml`. +4. Submit the Pull Request. diff --git a/conformance/resources/manifests/manifests.yaml b/conformance/resources/manifests/manifests.yaml new file mode 100644 index 00000000..7b43b784 --- /dev/null +++ b/conformance/resources/manifests/manifests.yaml @@ -0,0 +1,49 @@ +# Base Kubernetes resources for the Gateway API Inference Extension conformance tests. +# This includes namespaces and a minimal set of resources (Gateway, Backend) +# required by many tests. More specific resources should be defined within +# individual test files or other resource directories (e.g., sample_backends). + +--- +# Namespace for core infrastructure like Gateways. +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-conformance-infra + labels: + gateway-conformance: infra + +--- +# Namespace for application backends (potentially simulating model servers +# or where InferencePools might reside in some tests). +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-conformance-app-backend + labels: + gateway-conformance: backend + +--- +# A basic Gateway resource that allows HTTPRoutes from the same namespace. +# Tests can use this as a parent reference for routes that target InferencePools. +# Using a simple echo server instead of an actual model server to simplify the test +# execution, this design may need to be revised based on the test case needs. +apiVersion: gateway.networking.k8s.io/v1 # Using v1 as per latest Gateway API standard +kind: Gateway +metadata: + name: same-namespace + namespace: gateway-conformance-infra +spec: + # The conformance suite runner will replace this placeholder + # with the actual GatewayClass name provided via flags. + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http # Standard listener name + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same # Restrict to same namespace initially for simplicity + kinds: + # Allows HTTPRoutes to attach, which can then reference InferencePools. + - group: gateway.networking.k8s.io + kind: HTTPRoute diff --git a/conformance/tests/basic/inferencepool_accepted.go b/conformance/tests/basic/inferencepool_accepted.go new file mode 100644 index 00000000..eae59404 --- /dev/null +++ b/conformance/tests/basic/inferencepool_accepted.go @@ -0,0 +1,60 @@ +/* +Copyright 2025 The Kubernetes 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 basic + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" // For standard condition types + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" // For standard feature names + + // Import the tests package to append to ConformanceTests + "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" + infrakubernetes "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes" +) + +func init() { + // Register the InferencePoolAccepted test case with the conformance suite. + // This ensures it will be discovered and run by the test runner. + tests.ConformanceTests = append(tests.ConformanceTests, InferencePoolAccepted) +} + +// InferencePoolAccepted defines the test case for verifying basic InferencePool acceptance. +var InferencePoolAccepted = suite.ConformanceTest{ + ShortName: "InferencePoolAccepted", + Description: "A minimal InferencePool resource should be accepted by the controller and report an Accepted condition", + Manifests: []string{"tests/basic/inferencepool_accepted.yaml"}, + Features: []features.FeatureName{}, + Test: func(t *testing.T, s *suite.ConformanceTestSuite) { + // created by the associated manifest file. + poolNN := types.NamespacedName{Name: "inferencepool-basic-accepted", Namespace: "gateway-conformance-app-backend"} + + t.Run("InferencePool should have Accepted condition set to True", func(t *testing.T) { + // Define the expected status condition. We use the standard "Accepted" + // condition type from the Gateway API for consistency. + acceptedCondition := metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAccepted), // Standard condition type + Status: metav1.ConditionTrue, + Reason: "", // "" means we don't strictly check the Reason for this basic test. + } + infrakubernetes.InferencePoolMustHaveCondition(t, s.Client, s.TimeoutConfig, poolNN, acceptedCondition) + }) + }, +} diff --git a/conformance/tests/basic/inferencepool_accepted.yaml b/conformance/tests/basic/inferencepool_accepted.yaml new file mode 100644 index 00000000..8ae327d8 --- /dev/null +++ b/conformance/tests/basic/inferencepool_accepted.yaml @@ -0,0 +1,27 @@ +# Basic InferencePool for acceptance testing. +# This manifest defines the minimal required fields to create a valid +# InferencePool resource, which the InferencePoolAccepted test will use +# to verify that the controller recognizes and accepts the resource. + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + # This name must match the 'poolNN' variable defined in the + # conformance/tests/basic/inferencepool_accepted.go test file. + name: inferencepool-basic-accepted + # This namespace should be one created by the base manifests. + namespace: gateway-conformance-app-backend +spec: + # --- Selector (Required) --- + # Selects the Pods belonging to this pool. + selector: + app: "infra-backend-v1" + + # --- Target Port (Required) --- + # The port the model server container listens on. + targetPortNumber: 3000 + + # --- Extension Reference --- + # GKE-specific configuration reference. + extensionRef: + name: infra-backend-v1-epp diff --git a/conformance/tests/main.go b/conformance/tests/main.go new file mode 100644 index 00000000..fc66c765 --- /dev/null +++ b/conformance/tests/main.go @@ -0,0 +1,35 @@ +/* +Copyright 2025 The Kubernetes 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 tests is the root package for all Gateway API Inference Extension +// conformance test implementations. +package tests + +import ( + // Importing the suite package to access the ConformanceTest struct definition. + // For initial version directly importing from the core gateway-api repo. + // This may be adjusted in the future if we have need to create a copy of + // the suite utilities. + "sigs.k8s.io/gateway-api/conformance/utils/suite" + // Do NOT add blank imports for specific test packages here. + // They should be added to the main conformance package instead + // to avoid import cycles. +) + +// ConformanceTests holds all the conformance tests definitions for the +// Gateway API Inference Extension suite. Tests are registered from other packages +// using init() functions like the one in the basic package. +var ConformanceTests []suite.ConformanceTest diff --git a/conformance/utils/assertions.go b/conformance/utils/assertions.go new file mode 100644 index 00000000..c77d0fc5 --- /dev/null +++ b/conformance/utils/assertions.go @@ -0,0 +1,25 @@ +/* +Copyright 2025 The Kubernetes 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 assertions contains custom assertion helper functions used within +// the Gateway API Inference Extension conformance test suite. +package assertions + +// TODO: Implement custom assertion functions specific to Inference Extension testing. +// Examples might include: +// - Asserting specific fields or structures within an inference API response body. +// - Asserting specific metrics reported by mock model servers or EPPs. +// - Asserting specific conditions or status fields unique to InferencePool or InferenceModel. diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go new file mode 100644 index 00000000..3d517863 --- /dev/null +++ b/conformance/utils/kubernetes/helpers.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 The Kubernetes 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 kubernetes contains helper functions for interacting with +// Kubernetes objects within the conformance test suite. +package kubernetes + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + // Import necessary utilities from the core Gateway API conformance suite + "sigs.k8s.io/gateway-api/conformance/utils/config" +) + +// InferencePoolMustHaveCondition waits for the specified InferencePool resource +// to exist and report the expected status condition. +// This is a placeholder and needs full implementation. +// +// TODO: Implement the actual logic for this helper function. +// It should fetch the InferencePool using the provided client and check its +// Status.Conditions field, polling until the condition is met or a timeout occurs. +// like HTTPRouteMustHaveCondition. +func InferencePoolMustHaveCondition(t *testing.T, c client.Client, timeoutConfig config.TimeoutConfig, poolNN types.NamespacedName, expectedCondition metav1.Condition) { + t.Helper() // Marks this function as a test helper + + // Placeholder implementation: Log and skip the check. + t.Logf("Verification for InferencePool condition (%s=%s) on %s - Placeholder: Skipping check.", + expectedCondition.Type, expectedCondition.Status, poolNN.String()) + + // Skip the test using this helper until it's fully implemented. + t.Skip("InferencePoolMustHaveCondition helper not yet implemented") +} diff --git a/conformance/utils/traffic/traffic.go b/conformance/utils/traffic/traffic.go new file mode 100644 index 00000000..4f13f980 --- /dev/null +++ b/conformance/utils/traffic/traffic.go @@ -0,0 +1,22 @@ +/* +Copyright 2025 The Kubernetes 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 traffic contains helper functions specifically for generating, +// sending, and validating network traffic related to inference workloads +// within the Gateway API Inference Extension conformance tests. +package traffic + +// TODO: Add helpers for specific inference protocols or request patterns as needed. diff --git a/go.mod b/go.mod index 076bdf4b..30d0487e 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,8 @@ require ( k8s.io/component-base v0.32.4 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.4 - sigs.k8s.io/structured-merge-diff/v4 v4.7.0 + sigs.k8s.io/gateway-api v1.2.1 + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 sigs.k8s.io/yaml v1.4.0 ) @@ -42,17 +43,17 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fatih/color v1.16.0 // indirect + github.com/fatih/color v1.17.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect @@ -67,10 +68,10 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect - github.com/imdario/mergo v0.3.11 // indirect + github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -128,6 +129,6 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect - sigs.k8s.io/controller-tools v0.14.0 // indirect + sigs.k8s.io/controller-tools v0.16.3 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect ) diff --git a/go.sum b/go.sum index 0258fc7a..6688c578 100644 --- a/go.sum +++ b/go.sum @@ -23,25 +23,24 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q= github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elastic/crd-ref-docs v0.1.0 h1:Cr5kz89QB3Iuuj7dhAfLMApCrChEGAaIBTxGk/xuRKw= github.com/elastic/crd-ref-docs v0.1.0/go.mod h1:X83mMBdJt05heJUYiS3T0yJ/JkCuliuhSUNav5Gjo/U= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= +github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= +github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -55,12 +54,10 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= @@ -96,14 +93,14 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -114,11 +111,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -294,7 +288,6 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -326,13 +319,15 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcp sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= -sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A= -sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= +sigs.k8s.io/controller-tools v0.16.3 h1:z48C5/d4jCVQQvtiSBL5MYyZ3EO2eFIOXrIKMgHVhFY= +sigs.k8s.io/controller-tools v0.16.3/go.mod h1:AEj6k+w1kYpLZv2einOH3mj52ips4W/6FUjnB5tkJGs= +sigs.k8s.io/gateway-api v1.2.1 h1:fZZ/+RyRb+Y5tGkwxFKuYuSRQHu9dZtbjenblleOLHM= +sigs.k8s.io/gateway-api v1.2.1/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016 h1:kXv6kKdoEtedwuqMmkqhbkgvYKeycVbC8+iPCP9j5kQ= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= From fc3c173fd4f9ddad4364cdc82dc73592e66ff905 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Tue, 29 Apr 2025 09:22:02 -0700 Subject: [PATCH 254/260] Move scheduler initialization up to the main (#757) --- cmd/epp/main.go | 3 +++ pkg/epp/handlers/request.go | 5 +++++ pkg/epp/server/runserver.go | 4 ++-- test/integration/epp/hermetic_test.go | 2 ++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index c0a87e62..bac4b852 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -41,6 +41,7 @@ import ( backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -169,6 +170,7 @@ func run() error { datastore := datastore.NewDatastore(ctx, pmf) + scheduler := scheduling.NewScheduler(datastore) serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, @@ -178,6 +180,7 @@ func run() error { SecureServing: *secureServing, CertPath: *certPath, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, + Scheduler: scheduler, } if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { setupLog.Error(err, "Failed to setup ext-proc controllers") diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index 8d30e543..cfcd82ec 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -46,6 +46,10 @@ func (s *StreamingServer) HandleRequestBody( if !ok { return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} } + prompt, ok := requestBodyMap["prompt"].(string) + if !ok { + return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "prompt not found in request"} + } modelName := model @@ -66,6 +70,7 @@ func (s *StreamingServer) HandleRequestBody( Model: model, ResolvedTargetModel: modelName, Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, + Prompt: prompt, } logger.V(logutil.DEBUG).Info("LLM request assembled", "request", llmReq) diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 0c0a6a6d..687a555c 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -35,7 +35,6 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/controller" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" ) // ExtProcServerRunner provides methods to manage an external process server. @@ -49,6 +48,7 @@ type ExtProcServerRunner struct { CertPath string UseStreaming bool RefreshPrometheusMetricsInterval time.Duration + Scheduler handlers.Scheduler // This should only be used in tests. We won't need this once we don't inject metrics in the tests. // TODO:(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/432) Cleanup @@ -137,7 +137,7 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { } else { srv = grpc.NewServer() } - extProcServer := handlers.NewStreamingServer(scheduling.NewScheduler(r.Datastore), r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) + extProcServer := handlers.NewStreamingServer(r.Scheduler, r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore) extProcPb.RegisterExternalProcessorServer( srv, extProcServer, diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 35361329..c63fd017 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -65,6 +65,7 @@ import ( backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -1351,6 +1352,7 @@ func BeforeSuite() func() { // Adjust from defaults serverRunner.PoolNamespacedName = types.NamespacedName{Name: "vllm-llama3-8b-instruct-pool", Namespace: "default"} serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) + serverRunner.Scheduler = scheduling.NewScheduler(serverRunner.Datastore) serverRunner.SecureServing = false if err := serverRunner.SetupWithManager(context.Background(), mgr); err != nil { From 927c700d6ff876e758b96a50f69d99d00e25277f Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Tue, 29 Apr 2025 14:09:54 -0400 Subject: [PATCH 255/260] Add inference_extension_info metric for project metadata (#744) Start with just commit, version information will be added in a follow-up change. Verified: ``` inference_extension_info{commit="60f8c57bb95b656a75d27564d5ff01c060bcdba5"} 1 ``` --- Dockerfile | 3 ++- cmd/epp/main.go | 2 ++ pkg/epp/metrics/metrics.go | 44 ++++++++++++++++++++++++++++++++++++++ site-src/guides/metrics.md | 2 ++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8fb00dfb..d050b869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,9 @@ COPY cmd ./cmd COPY pkg ./pkg COPY internal ./internal COPY api ./api +COPY .git ./.git WORKDIR /src/cmd/epp -RUN go build -o /epp +RUN go build -buildvcs=true -o /epp ## Multistage deploy FROM ${BASE_IMAGE} diff --git a/cmd/epp/main.go b/cmd/epp/main.go index bac4b852..2bd779c5 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -250,6 +250,8 @@ func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds datastore. func registerMetricsHandler(mgr manager.Manager, port int, cfg *rest.Config) error { metrics.Register() + metrics.RecordInferenceExtensionInfo() + // Init HTTP server. h, err := metricsHandlerWithAuthenticationAndAuthorization(cfg) if err != nil { diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 56dcfca8..6df3dab3 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -18,6 +18,7 @@ package metrics import ( "context" + "runtime/debug" "sync" "time" @@ -31,6 +32,12 @@ const ( InferenceModelComponent = "inference_model" InferencePoolComponent = "inference_pool" EPPComponent = "endpoint_picker" + InferenceExtension = "inference_extension" +) + +var ( + // The git hash of the latest commit in the build. + CommitHash string ) var ( @@ -191,6 +198,17 @@ var ( }, []string{"plugin_type", "plugin_name"}, ) + + // Info Metrics + InferenceExtensionInfo = compbasemetrics.NewGaugeVec( + &compbasemetrics.GaugeOpts{ + Subsystem: InferenceExtension, + Name: "info", + Help: "General information of the current build of Inference Extension.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{"commit"}, + ) ) var registerMetrics sync.Once @@ -213,6 +231,8 @@ func Register() { legacyregistry.MustRegister(inferencePoolReadyPods) legacyregistry.MustRegister(SchedulerPluginProcessingLatencies) + + legacyregistry.MustRegister(InferenceExtensionInfo) }) } @@ -315,3 +335,27 @@ func RecordinferencePoolReadyPods(name string, runningPods float64) { func RecordSchedulerPluginProcessingLatency(pluginType, pluginName string, duration time.Duration) { SchedulerPluginProcessingLatencies.WithLabelValues(pluginType, pluginName).Observe(duration.Seconds()) } + +func RecordInferenceExtensionInfo() { + if CommitHash != "" { + InferenceExtensionInfo.WithLabelValues(CommitHash).Set(1) + } +} + +func init() { + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + + var Commit = func(i *debug.BuildInfo) string { + for _, setting := range i.Settings { + if setting.Key == "vcs.revision" { + return setting.Value + } + } + return "" + }(info) + + CommitHash = Commit +} diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index d16c7d47..ab3ba3fd 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -35,6 +35,8 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ | inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | | inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | | inference_pool_ready_pods | Gauge | The number of ready pods for an inference server pool. | `name`=<inference-pool-name> | ALPHA | +| inference_extension_info | Gauge | The general information of the current build. | `commit`=<hash-of-the-build> | ALPHA | + ## Scrape Metrics From 2d2db354083b8653ed24be2b7b40ac35f2fb4478 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Wed, 30 Apr 2025 14:33:54 -0400 Subject: [PATCH 256/260] chore: make SchedulerConfig fields configurable (#764) Signed-off-by: Shane Utt --- pkg/epp/scheduling/config.go | 26 +++++++++------ pkg/epp/scheduling/scheduler.go | 10 +++--- pkg/epp/scheduling/scheduler_test.go | 50 ++++++++++++++-------------- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index 4ed109af..0c33088b 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -18,12 +18,18 @@ package scheduling import "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" +// SchedulerConfig provides a configuration for the scheduler which includes +// items like filters, scorers, etc that influence routing decisions. +// +// This is not threadsafe and the machinery here does not support dynamically +// changing this at runtime, so this should be set once on startup and not +// changed thereafter. type SchedulerConfig struct { - preSchedulePlugins []plugins.PreSchedule - filters []plugins.Filter - scorers map[plugins.Scorer]int // map from scorer to weight - picker plugins.Picker - postSchedulePlugins []plugins.PostSchedule + PreSchedulePlugins []plugins.PreSchedule + Filters []plugins.Filter + Scorers map[plugins.Scorer]int // map from scorer to weight + Picker plugins.Picker + PostSchedulePlugins []plugins.PostSchedule } var defPlugin = &defaultPlugin{} @@ -33,9 +39,9 @@ var defPlugin = &defaultPlugin{} // For build time plugins changes, it's recommended to change the defaultConfig variable in this file. var defaultConfig = &SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{}, - filters: []plugins.Filter{defPlugin}, - scorers: map[plugins.Scorer]int{}, - picker: defPlugin, - postSchedulePlugins: []plugins.PostSchedule{}, + PreSchedulePlugins: []plugins.PreSchedule{}, + Filters: []plugins.Filter{defPlugin}, + Scorers: map[plugins.Scorer]int{}, + Picker: defPlugin, + PostSchedulePlugins: []plugins.PostSchedule{}, } diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 1a1d67b5..5078fc54 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -74,11 +74,11 @@ func NewScheduler(datastore Datastore) *Scheduler { func NewSchedulerWithConfig(datastore Datastore, config *SchedulerConfig) *Scheduler { return &Scheduler{ datastore: datastore, - preSchedulePlugins: config.preSchedulePlugins, - filters: config.filters, - scorers: config.scorers, - picker: config.picker, - postSchedulePlugins: config.postSchedulePlugins, + preSchedulePlugins: config.PreSchedulePlugins, + filters: config.Filters, + scorers: config.Scorers, + picker: config.Picker, + postSchedulePlugins: config.PostSchedulePlugins, } } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index b44c7ac2..2d773283 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -273,14 +273,14 @@ func TestSchedulePlugins(t *testing.T) { { name: "all plugins executed successfully, all scorers with same weight", config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp2}, - scorers: map[plugins.Scorer]int{ + PreSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + Filters: []plugins.Filter{tp1, tp2}, + Scorers: map[plugins.Scorer]int{ tp1: 1, tp2: 1, }, - picker: pickerPlugin, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + Picker: pickerPlugin, + PostSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -295,14 +295,14 @@ func TestSchedulePlugins(t *testing.T) { { name: "all plugins executed successfully, different scorers weights", config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp2}, - scorers: map[plugins.Scorer]int{ + PreSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + Filters: []plugins.Filter{tp1, tp2}, + Scorers: map[plugins.Scorer]int{ tp1: 60, tp2: 40, }, - picker: pickerPlugin, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + Picker: pickerPlugin, + PostSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -317,14 +317,14 @@ func TestSchedulePlugins(t *testing.T) { { name: "filter all", config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp_filterAll}, - scorers: map[plugins.Scorer]int{ + PreSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, + Filters: []plugins.Filter{tp1, tp_filterAll}, + Scorers: map[plugins.Scorer]int{ tp1: 1, tp2: 1, }, - picker: pickerPlugin, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, + Picker: pickerPlugin, + PostSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -339,17 +339,17 @@ func TestSchedulePlugins(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { // Reset all plugins before each new test case. - for _, plugin := range test.config.preSchedulePlugins { + for _, plugin := range test.config.PreSchedulePlugins { plugin.(*TestPlugin).reset() } - for _, plugin := range test.config.filters { + for _, plugin := range test.config.Filters { plugin.(*TestPlugin).reset() } - for plugin := range test.config.scorers { + for plugin := range test.config.Scorers { plugin.(*TestPlugin).reset() } - test.config.picker.(*TestPlugin).reset() - for _, plugin := range test.config.postSchedulePlugins { + test.config.Picker.(*TestPlugin).reset() + for _, plugin := range test.config.PostSchedulePlugins { plugin.(*TestPlugin).reset() } @@ -378,21 +378,21 @@ func TestSchedulePlugins(t *testing.T) { } // Validate plugin execution counts dynamically - for _, plugin := range test.config.preSchedulePlugins { + for _, plugin := range test.config.PreSchedulePlugins { tp, _ := plugin.(*TestPlugin) if tp.PreScheduleCallCount != 1 { t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", plugin.Name(), tp.PreScheduleCallCount) } } - for _, plugin := range test.config.filters { + for _, plugin := range test.config.Filters { tp, _ := plugin.(*TestPlugin) if tp.FilterCallCount != 1 { t.Errorf("Plugin %s Filter() called %d times, expected 1", plugin.Name(), tp.FilterCallCount) } } - for plugin := range test.config.scorers { + for plugin := range test.config.Scorers { tp, _ := plugin.(*TestPlugin) if tp.ScoreCallCount != 1 { t.Errorf("Plugin %s Score() called %d times, expected 1", plugin.Name(), tp.ScoreCallCount) @@ -402,7 +402,7 @@ func TestSchedulePlugins(t *testing.T) { } } - tp, _ := test.config.picker.(*TestPlugin) + tp, _ := test.config.Picker.(*TestPlugin) if tp.NumOfPickerCandidates != test.numPodsToScore { t.Errorf("Picker plugin %s Pick() called with %d candidates, expected %d", tp.Name(), tp.NumOfPickerCandidates, tp.NumOfScoredPods) } @@ -413,7 +413,7 @@ func TestSchedulePlugins(t *testing.T) { t.Errorf("winnder pod score %v, expected %v", tp.WinnderPodScore, test.targetPodScore) } - for _, plugin := range test.config.postSchedulePlugins { + for _, plugin := range test.config.PostSchedulePlugins { tp, _ := plugin.(*TestPlugin) if tp.PostScheduleCallCount != 1 { t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", plugin.Name(), tp.PostScheduleCallCount) From 69af61e07bbce04660b3aef60b81bebcf2925f01 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Wed, 30 Apr 2025 14:51:55 -0400 Subject: [PATCH 257/260] fix: pass commit hash from the cloud build default variable (#763) TESTED with both local run of: - make image-push - cloud-build-local command --- Dockerfile | 4 ++-- Makefile | 2 ++ cloudbuild.yaml | 1 + pkg/epp/metrics/metrics.go | 25 +++---------------------- 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index d050b869..9cb62e28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ FROM ${BUILDER_IMAGE} AS builder ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 +ARG COMMIT_SHA=unknown # Dependencies WORKDIR /src @@ -19,9 +20,8 @@ COPY cmd ./cmd COPY pkg ./pkg COPY internal ./internal COPY api ./api -COPY .git ./.git WORKDIR /src/cmd/epp -RUN go build -buildvcs=true -o /epp +RUN go build -ldflags="-X sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics.CommitSHA=${COMMIT_SHA}" -o /epp ## Multistage deploy FROM ${BASE_IMAGE} diff --git a/Makefile b/Makefile index 4826a029..884d4229 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,7 @@ CONTAINER_TOOL ?= docker SHELL = /usr/bin/env bash -o pipefail .SHELLFLAGS = -ec +GIT_COMMIT_SHA ?= "$(shell git rev-parse HEAD 2>/dev/null)" GIT_TAG ?= $(shell git describe --tags --dirty --always) PLATFORMS ?= linux/amd64 DOCKER_BUILDX_CMD ?= docker buildx @@ -175,6 +176,7 @@ image-build: ## Build the EPP image using Docker Buildx. --platform=$(PLATFORMS) \ --build-arg BASE_IMAGE=$(BASE_IMAGE) \ --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ + --build-arg COMMIT_SHA=${GIT_COMMIT_SHA} \ $(PUSH) \ $(LOAD) \ $(IMAGE_BUILD_EXTRA_OPTS) ./ diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 6043d225..f05c8c00 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -12,6 +12,7 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint + - GIT_COMMIT_SHA=$COMMIT_SHA - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 6df3dab3..0752713f 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -18,7 +18,6 @@ package metrics import ( "context" - "runtime/debug" "sync" "time" @@ -37,7 +36,7 @@ const ( var ( // The git hash of the latest commit in the build. - CommitHash string + CommitSHA string ) var ( @@ -337,25 +336,7 @@ func RecordSchedulerPluginProcessingLatency(pluginType, pluginName string, durat } func RecordInferenceExtensionInfo() { - if CommitHash != "" { - InferenceExtensionInfo.WithLabelValues(CommitHash).Set(1) + if CommitSHA != "" { + InferenceExtensionInfo.WithLabelValues(CommitSHA).Set(1) } } - -func init() { - info, ok := debug.ReadBuildInfo() - if !ok { - return - } - - var Commit = func(i *debug.BuildInfo) string { - for _, setting := range i.Settings { - if setting.Key == "vcs.revision" { - return setting.Value - } - } - return "" - }(info) - - CommitHash = Commit -} From 4c7ed4a1d76f92482695220f21a4db69026702c9 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 30 Apr 2025 12:35:58 -0700 Subject: [PATCH 258/260] Small refactor to capture request data for route. (#765) --- pkg/epp/handlers/request.go | 12 ++++++++++-- pkg/epp/handlers/server.go | 19 +++++++++++++++---- ...streamingserver_test.go => server_test.go} | 0 3 files changed, 25 insertions(+), 6 deletions(-) rename pkg/epp/handlers/{streamingserver_test.go => server_test.go} (100%) diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index cfcd82ec..65d082c8 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -35,11 +35,10 @@ import ( func (s *StreamingServer) HandleRequestBody( ctx context.Context, reqCtx *RequestContext, - req *extProcPb.ProcessingRequest, - requestBodyMap map[string]interface{}, ) (*RequestContext, error) { var requestBodyBytes []byte logger := log.FromContext(ctx) + requestBodyMap := reqCtx.Request.Body // Resolve target models. model, ok := requestBodyMap["model"].(string) @@ -152,6 +151,15 @@ func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *Requ } endpoint := pod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) s.populateRequestHeaderResponse(reqCtx, endpoint, 0) + return nil + } + + for _, header := range req.RequestHeaders.Headers.Headers { + if header.RawValue != nil { + reqCtx.Request.Headers[header.Key] = string(header.RawValue) + } else { + reqCtx.Request.Headers[header.Key] = header.Value + } } return nil } diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 630baef3..646d6fee 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -82,6 +82,7 @@ type RequestContext struct { ResponseComplete bool ResponseStatusCode string RequestRunning bool + Request *Request RequestState StreamRequestState modelServerStreaming bool @@ -95,6 +96,10 @@ type RequestContext struct { respTrailerResp *extProcPb.ProcessingResponse } +type Request struct { + Headers map[string]string + Body map[string]interface{} +} type StreamRequestState int const ( @@ -118,10 +123,14 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // See https://github.com/envoyproxy/envoy/issues/17540. reqCtx := &RequestContext{ RequestState: RequestReceived, + Request: &Request{ + Headers: make(map[string]string), + Body: make(map[string]interface{}), + }, } var body []byte - var requestBody, responseBody map[string]interface{} + var responseBody map[string]interface{} // Create error handling var as each request should only report once for // error metrics. This doesn't cover the error "Cannot receive stream request" because @@ -167,15 +176,17 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // Message is buffered, we can read and decode. if v.RequestBody.EndOfStream { loggerTrace.Info("decoding") - err = json.Unmarshal(body, &requestBody) + err = json.Unmarshal(body, &reqCtx.Request.Body) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") + // TODO: short circuit and send the body back as is (this could be an envoy error), currently we drop + // whatever the body request would have been and send our immediate response instead. } // Body stream complete. Allocate empty slice for response to use. body = []byte{} - reqCtx, err = s.HandleRequestBody(ctx, reqCtx, req, requestBody) + reqCtx, err = s.HandleRequestBody(ctx, reqCtx) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error handling body") } else { @@ -256,7 +267,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) loggerTrace.Info("stream completed") // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. - // using the standard 'err' var will send an immediate error response back to the caller. + // Using the standard 'err' var will send an immediate error response back to the caller. var responseErr error responseErr = json.Unmarshal(body, &responseBody) if responseErr != nil { diff --git a/pkg/epp/handlers/streamingserver_test.go b/pkg/epp/handlers/server_test.go similarity index 100% rename from pkg/epp/handlers/streamingserver_test.go rename to pkg/epp/handlers/server_test.go From a04d395e538fc8bb99b34eddfc96af6ae43e2b58 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Wed, 30 Apr 2025 13:07:55 -0700 Subject: [PATCH 259/260] Add queue and kv-cache scorers (#762) * Add queue and kv-cache scorers * Remove helper function --- .../plugins/picker/max_score_picker.go | 16 ++++ pkg/epp/scheduling/plugins/scorer/kvcache.go | 35 +++++++ .../scheduling/plugins/scorer/kvcache_test.go | 95 +++++++++++++++++++ pkg/epp/scheduling/plugins/scorer/queue.go | 61 ++++++++++++ .../scheduling/plugins/scorer/queue_test.go | 85 +++++++++++++++++ 5 files changed, 292 insertions(+) create mode 100644 pkg/epp/scheduling/plugins/scorer/kvcache.go create mode 100644 pkg/epp/scheduling/plugins/scorer/kvcache_test.go create mode 100644 pkg/epp/scheduling/plugins/scorer/queue.go create mode 100644 pkg/epp/scheduling/plugins/scorer/queue_test.go diff --git a/pkg/epp/scheduling/plugins/picker/max_score_picker.go b/pkg/epp/scheduling/plugins/picker/max_score_picker.go index 1705b7dd..a6d7b397 100644 --- a/pkg/epp/scheduling/plugins/picker/max_score_picker.go +++ b/pkg/epp/scheduling/plugins/picker/max_score_picker.go @@ -1,3 +1,19 @@ +/* +Copyright 2025 The Kubernetes 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 picker import ( diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache.go b/pkg/epp/scheduling/plugins/scorer/kvcache.go new file mode 100644 index 00000000..0877691d --- /dev/null +++ b/pkg/epp/scheduling/plugins/scorer/kvcache.go @@ -0,0 +1,35 @@ +/* +Copyright 2025 The Kubernetes 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 scorer + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +type KVCacheScorer struct{} + +func (ss *KVCacheScorer) Name() string { + return "kv-cache" +} + +func (ss *KVCacheScorer) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { + scores := make(map[types.Pod]float64, len(pods)) + for _, pod := range pods { + scores[pod] = 1 - pod.GetMetrics().KVCacheUsagePercent + } + return scores +} diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache_test.go b/pkg/epp/scheduling/plugins/scorer/kvcache_test.go new file mode 100644 index 00000000..257a58c1 --- /dev/null +++ b/pkg/epp/scheduling/plugins/scorer/kvcache_test.go @@ -0,0 +1,95 @@ +/* +Copyright 2025 The Kubernetes 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 scorer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +func TestKvCacheScorer(t *testing.T) { + tests := []struct { + name string + pods []types.Pod + expectedScoresPod map[int]float64 // Map of pod index to expected score + }{ + { + name: "Different KV cache utilization", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.8}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.5}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.0}}, + }, + expectedScoresPod: map[int]float64{ + 0: 0.2, // Highest KV cache usage (0.8) gets lowest score (1-0.8=0.2) + 1: 0.5, // Medium KV cache usage (0.5) gets medium score (1-0.5=0.5) + 2: 1.0, // No KV cache usage (0.0) gets highest score (1-0=1.0) + }, + }, + { + name: "Same KV cache utilization", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.6}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.6}}, + }, + expectedScoresPod: map[int]float64{ + 0: 0.4, // Both get same score (1-0.6=0.4) + 1: 0.4, + }, + }, + { + name: "Zero KV cache utilization", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.0}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.0}}, + }, + expectedScoresPod: map[int]float64{ + 0: 1.0, // No KV cache usage gets highest score + 1: 1.0, + }, + }, + { + name: "Full KV cache utilization", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 1.0}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.5}}, + }, + expectedScoresPod: map[int]float64{ + 0: 0.0, // Full KV cache (1.0) gets lowest score (1-1=0) + 1: 0.5, // Half KV cache (0.5) gets medium score (1-0.5=0.5) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := types.NewSchedulingContext(context.Background(), &types.LLMRequest{}, tt.pods) + scorer := &KVCacheScorer{} + scores := scorer.Score(ctx, tt.pods) + + for i, pod := range tt.pods { + expectedScore := tt.expectedScoresPod[i] + assert.InDelta(t, expectedScore, scores[pod], 0.0001, "Pod %d should have score %f", i, expectedScore) + } + }) + } +} diff --git a/pkg/epp/scheduling/plugins/scorer/queue.go b/pkg/epp/scheduling/plugins/scorer/queue.go new file mode 100644 index 00000000..3df9d414 --- /dev/null +++ b/pkg/epp/scheduling/plugins/scorer/queue.go @@ -0,0 +1,61 @@ +/* +Copyright 2025 The Kubernetes 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 scorer + +import ( + "math" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +type QueueScorer struct{} + +func (q *QueueScorer) Name() string { + return "queue" +} + +func (q *QueueScorer) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { + minQueueSize := math.MaxInt + maxQueueSize := math.MinInt + + // Iterate through the remaining pods to find min and max + for _, pod := range pods { + queueSize := pod.GetMetrics().WaitingQueueSize + if queueSize < minQueueSize { + minQueueSize = queueSize + } + if queueSize > maxQueueSize { + maxQueueSize = queueSize + } + } + + // podScoreFunc calculates the score based on the queue size of each pod. Longer queue gets a lower score. + podScoreFunc := func(pod types.Pod) float64 { + if maxQueueSize == minQueueSize { + // If all pods have the same queue size, return a neutral score + return 1.0 + } + return float64(maxQueueSize-pod.GetMetrics().WaitingQueueSize) / float64(maxQueueSize-minQueueSize) + } + + // Create a map to hold the scores for each pod + scores := make(map[types.Pod]float64, len(pods)) + for _, pod := range pods { + scores[pod] = podScoreFunc(pod) + } + return scores +} diff --git a/pkg/epp/scheduling/plugins/scorer/queue_test.go b/pkg/epp/scheduling/plugins/scorer/queue_test.go new file mode 100644 index 00000000..907681b2 --- /dev/null +++ b/pkg/epp/scheduling/plugins/scorer/queue_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2025 The Kubernetes 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 scorer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +func TestQueueScorer(t *testing.T) { + tests := []struct { + name string + pods []types.Pod + expectedScoresPod map[int]float64 // Map of pod index to expected score + }{ + { + name: "Different queue sizes", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 10}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 5}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 0}}, + }, + expectedScoresPod: map[int]float64{ + 0: 0.0, // Longest queue (10) gets lowest score + 1: 0.5, // Medium queue (5) gets medium score + 2: 1.0, // Shortest queue (0) gets highest score + }, + }, + { + name: "Same queue sizes", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 5}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 5}}, + }, + expectedScoresPod: map[int]float64{ + 0: 1.0, // When all pods have the same queue size, they get the same neutral score + 1: 1.0, + }, + }, + { + name: "Zero queue sizes", + pods: []types.Pod{ + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 0}}, + &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 0}}, + }, + expectedScoresPod: map[int]float64{ + 0: 1.0, + 1: 1.0, + }, + }, + } + + scorer := &QueueScorer{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := types.NewSchedulingContext(context.Background(), &types.LLMRequest{}, tt.pods) + scores := scorer.Score(ctx, tt.pods) + + for i, pod := range tt.pods { + expectedScore := tt.expectedScoresPod[i] + assert.InDelta(t, expectedScore, scores[pod], 0.0001, "Pod %d should have score %f", i, expectedScore) + } + }) + } +} From a6ee5590dc54fc3018a4e5951592dcf2a7b25c46 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Wed, 30 Apr 2025 14:43:55 -0700 Subject: [PATCH 260/260] Add scheduler e2e latency metric (#767) --- pkg/epp/metrics/metrics.go | 23 ++- pkg/epp/metrics/metrics_test.go | 45 +++++- .../scheduler_e2e_duration_seconds_metric | 15 ++ ...heduler_plugin_processing_latencies_metric | 134 +++++++++--------- pkg/epp/scheduling/scheduler.go | 5 + 5 files changed, 151 insertions(+), 71 deletions(-) create mode 100644 pkg/epp/metrics/testdata/scheduler_e2e_duration_seconds_metric diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 0752713f..6cc0cdb8 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -30,7 +30,6 @@ import ( const ( InferenceModelComponent = "inference_model" InferencePoolComponent = "inference_pool" - EPPComponent = "endpoint_picker" InferenceExtension = "inference_extension" ) @@ -184,10 +183,22 @@ var ( []string{"name"}, ) - // Scheduler Plugin Metrics + // Scheduler Metrics + SchedulerE2ELatency = compbasemetrics.NewHistogramVec( + &compbasemetrics.HistogramOpts{ + Subsystem: InferenceExtension, + Name: "scheduler_e2e_duration_seconds", + Help: "End-to-end scheduling latency distribution in seconds.", + Buckets: []float64{ + 0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, + }, + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) SchedulerPluginProcessingLatencies = compbasemetrics.NewHistogramVec( &compbasemetrics.HistogramOpts{ - Subsystem: EPPComponent, + Subsystem: InferenceExtension, Name: "scheduler_plugin_duration_seconds", Help: "Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name.", Buckets: []float64{ @@ -230,6 +241,7 @@ func Register() { legacyregistry.MustRegister(inferencePoolReadyPods) legacyregistry.MustRegister(SchedulerPluginProcessingLatencies) + legacyregistry.MustRegister(SchedulerE2ELatency) legacyregistry.MustRegister(InferenceExtensionInfo) }) @@ -335,6 +347,11 @@ func RecordSchedulerPluginProcessingLatency(pluginType, pluginName string, durat SchedulerPluginProcessingLatencies.WithLabelValues(pluginType, pluginName).Observe(duration.Seconds()) } +// RecordSchedulerE2ELatency records the end-to-end scheduling latency. +func RecordSchedulerE2ELatency(duration time.Duration) { + SchedulerE2ELatency.WithLabelValues().Observe(duration.Seconds()) +} + func RecordInferenceExtensionInfo() { if CommitSHA != "" { InferenceExtensionInfo.WithLabelValues(CommitSHA).Set(1) diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index 81797e6d..a2311517 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -614,7 +614,50 @@ func TestSchedulerPluginProcessingLatencies(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantPluginLatencies, "endpoint_picker_scheduler_plugin_processing_latencies"); err != nil { + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantPluginLatencies, "inference_extension_scheduler_plugin_duration_seconds"); err != nil { + t.Error(err) + } + }) + } +} + +func TestSchedulerE2ELatency(t *testing.T) { + scenarios := []struct { + name string + durations []time.Duration + }{ + { + name: "multiple scheduling latencies", + durations: []time.Duration{ + 200 * time.Microsecond, // 0.00014s - should go in the 0.0002 bucket + 800 * time.Microsecond, // 0.0008s - should go in the 0.001 bucket + 1500 * time.Microsecond, // 0.0015s - should go in the 0.002 bucket + 3 * time.Millisecond, // 0.003s - should go in the 0.005 bucket + 8 * time.Millisecond, // 0.008s - should go in the 0.01 bucket + 15 * time.Millisecond, // 0.015s - should go in the 0.02 bucket + 30 * time.Millisecond, // 0.03s - should go in the 0.05 bucket + 75 * time.Millisecond, // 0.075s - should go in the 0.1 bucket + 150 * time.Millisecond, // 0.15s - should go in the +Inf bucket + }, + }, + } + Register() + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + for _, duration := range scenario.durations { + RecordSchedulerE2ELatency(duration) + } + + wantE2ELatency, err := os.Open("testdata/scheduler_e2e_duration_seconds_metric") + defer func() { + if err := wantE2ELatency.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantE2ELatency, "inference_extension_scheduler_e2e_duration_seconds"); err != nil { t.Error(err) } }) diff --git a/pkg/epp/metrics/testdata/scheduler_e2e_duration_seconds_metric b/pkg/epp/metrics/testdata/scheduler_e2e_duration_seconds_metric new file mode 100644 index 00000000..0bbb35b1 --- /dev/null +++ b/pkg/epp/metrics/testdata/scheduler_e2e_duration_seconds_metric @@ -0,0 +1,15 @@ +# HELP inference_extension_scheduler_e2e_duration_seconds [ALPHA] End-to-end scheduling latency distribution in seconds. +# TYPE inference_extension_scheduler_e2e_duration_seconds histogram +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.0001"} 0 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.0002"} 1 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.0005"} 1 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.001"} 2 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.002"} 3 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.005"} 4 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.01"} 5 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.02"} 6 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.05"} 7 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="0.1"} 8 +inference_extension_scheduler_e2e_duration_seconds_bucket{le="+Inf"} 9 +inference_extension_scheduler_e2e_duration_seconds_sum{} 0.2835 +inference_extension_scheduler_e2e_duration_seconds_count{} 9 diff --git a/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric b/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric index 8c11757f..669d64da 100644 --- a/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric +++ b/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric @@ -1,67 +1,67 @@ -# HELP endpoint_picker_scheduler_plugin_duration_seconds [ALPHA] Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name. -# TYPE endpoint_picker_scheduler_plugin_duration_seconds histogram -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.01"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.02"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.05"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.1"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="+Inf"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginA",plugin_type="PreSchedule"} 0.1 -endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginA",plugin_type="PreSchedule"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.01"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.02"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.05"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.1"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="+Inf"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginB",plugin_type="PostSchedule"} 0.2 -endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginB",plugin_type="PostSchedule"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.01"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.02"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.05"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.1"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="+Inf"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginC",plugin_type="Filter"} 0.05 -endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginC",plugin_type="Filter"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.001"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.002"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.005"} 0 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.01"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.02"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.05"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.1"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="+Inf"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginD",plugin_type="Scorer"} 0.01 -endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginD",plugin_type="Scorer"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0001"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0002"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0005"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.001"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.002"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.005"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.01"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.02"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.05"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.1"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="+Inf"} 1 -endpoint_picker_scheduler_plugin_duration_seconds_sum{plugin_name="PluginE",plugin_type="Picker"} 1e-05 -endpoint_picker_scheduler_plugin_duration_seconds_count{plugin_name="PluginE",plugin_type="Picker"} 1 +# HELP inference_extension_scheduler_plugin_duration_seconds [ALPHA] Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name. +# TYPE inference_extension_scheduler_plugin_duration_seconds histogram +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.01"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.02"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.05"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.1"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="+Inf"} 1 +inference_extension_scheduler_plugin_duration_seconds_sum{plugin_name="PluginA",plugin_type="PreSchedule"} 0.1 +inference_extension_scheduler_plugin_duration_seconds_count{plugin_name="PluginA",plugin_type="PreSchedule"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.01"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.02"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.05"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.1"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="+Inf"} 1 +inference_extension_scheduler_plugin_duration_seconds_sum{plugin_name="PluginB",plugin_type="PostSchedule"} 0.2 +inference_extension_scheduler_plugin_duration_seconds_count{plugin_name="PluginB",plugin_type="PostSchedule"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.0005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.01"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.02"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.05"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="0.1"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginC",plugin_type="Filter",le="+Inf"} 1 +inference_extension_scheduler_plugin_duration_seconds_sum{plugin_name="PluginC",plugin_type="Filter"} 0.05 +inference_extension_scheduler_plugin_duration_seconds_count{plugin_name="PluginC",plugin_type="Filter"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.0005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.001"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.002"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.005"} 0 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.01"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.02"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.05"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="0.1"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginD",plugin_type="Scorer",le="+Inf"} 1 +inference_extension_scheduler_plugin_duration_seconds_sum{plugin_name="PluginD",plugin_type="Scorer"} 0.01 +inference_extension_scheduler_plugin_duration_seconds_count{plugin_name="PluginD",plugin_type="Scorer"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0001"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0002"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.0005"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.001"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.002"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.005"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.01"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.02"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.05"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="0.1"} 1 +inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginE",plugin_type="Picker",le="+Inf"} 1 +inference_extension_scheduler_plugin_duration_seconds_sum{plugin_name="PluginE",plugin_type="Picker"} 1e-05 +inference_extension_scheduler_plugin_duration_seconds_count{plugin_name="PluginE",plugin_type="Picker"} 1 diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 5078fc54..245d0a5d 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -100,6 +100,11 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types logger := log.FromContext(ctx).WithValues("request", req) loggerDebug := logger.V(logutil.DEBUG) + scheduleStart := time.Now() + defer func() { + metrics.RecordSchedulerE2ELatency(time.Since(scheduleStart)) + }() + // Snapshot pod metrics from the datastore to: // 1. Reduce concurrent access to the datastore. // 2. Ensure consistent data during the scheduling operation of a request.

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 From af0634559feff06c522844b2b84ca8e3af8810bf Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 24 Mar 2025 14:24:33 -0700 Subject: [PATCH 146/260] update boilerplate template and generate output (#566) --- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.register.go | 2 +- .../applyconfiguration/api/v1alpha2/endpointpickerconfig.go | 2 +- client-go/applyconfiguration/api/v1alpha2/extension.go | 2 +- .../applyconfiguration/api/v1alpha2/extensionconnection.go | 2 +- client-go/applyconfiguration/api/v1alpha2/extensionreference.go | 2 +- client-go/applyconfiguration/api/v1alpha2/inferencemodel.go | 2 +- client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go | 2 +- .../applyconfiguration/api/v1alpha2/inferencemodelstatus.go | 2 +- client-go/applyconfiguration/api/v1alpha2/inferencepool.go | 2 +- client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go | 2 +- .../applyconfiguration/api/v1alpha2/inferencepoolstatus.go | 2 +- .../applyconfiguration/api/v1alpha2/poolobjectreference.go | 2 +- client-go/applyconfiguration/api/v1alpha2/poolstatus.go | 2 +- client-go/applyconfiguration/api/v1alpha2/targetmodel.go | 2 +- client-go/applyconfiguration/internal/internal.go | 2 +- client-go/applyconfiguration/utils.go | 2 +- client-go/clientset/versioned/clientset.go | 2 +- client-go/clientset/versioned/fake/clientset_generated.go | 2 +- client-go/clientset/versioned/fake/doc.go | 2 +- client-go/clientset/versioned/fake/register.go | 2 +- client-go/clientset/versioned/scheme/doc.go | 2 +- client-go/clientset/versioned/scheme/register.go | 2 +- client-go/clientset/versioned/typed/api/v1alpha2/api_client.go | 2 +- client-go/clientset/versioned/typed/api/v1alpha2/doc.go | 2 +- client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go | 2 +- .../versioned/typed/api/v1alpha2/fake/fake_api_client.go | 2 +- .../versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go | 2 +- .../versioned/typed/api/v1alpha2/fake/fake_inferencepool.go | 2 +- .../versioned/typed/api/v1alpha2/generated_expansion.go | 2 +- .../clientset/versioned/typed/api/v1alpha2/inferencemodel.go | 2 +- .../clientset/versioned/typed/api/v1alpha2/inferencepool.go | 2 +- client-go/informers/externalversions/api/interface.go | 2 +- .../informers/externalversions/api/v1alpha2/inferencemodel.go | 2 +- .../informers/externalversions/api/v1alpha2/inferencepool.go | 2 +- client-go/informers/externalversions/api/v1alpha2/interface.go | 2 +- client-go/informers/externalversions/factory.go | 2 +- client-go/informers/externalversions/generic.go | 2 +- .../externalversions/internalinterfaces/factory_interfaces.go | 2 +- client-go/listers/api/v1alpha2/expansion_generated.go | 2 +- client-go/listers/api/v1alpha2/inferencemodel.go | 2 +- client-go/listers/api/v1alpha2/inferencepool.go | 2 +- hack/boilerplate.go.txt | 2 +- 43 files changed, 43 insertions(+), 43 deletions(-) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 4dad0eff..3070cdcb 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -1,7 +1,7 @@ //go:build !ignore_autogenerated /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v1alpha2/zz_generated.register.go b/api/v1alpha2/zz_generated.register.go index 3c2732a5..07dbf92b 100644 --- a/api/v1alpha2/zz_generated.register.go +++ b/api/v1alpha2/zz_generated.register.go @@ -2,7 +2,7 @@ // +build !ignore_autogenerated /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go b/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go index 007b8870..679cdba8 100644 --- a/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go +++ b/client-go/applyconfiguration/api/v1alpha2/endpointpickerconfig.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/extension.go b/client-go/applyconfiguration/api/v1alpha2/extension.go index 5e17e030..731467b7 100644 --- a/client-go/applyconfiguration/api/v1alpha2/extension.go +++ b/client-go/applyconfiguration/api/v1alpha2/extension.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go b/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go index 2a59b830..bd968ec6 100644 --- a/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go +++ b/client-go/applyconfiguration/api/v1alpha2/extensionconnection.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/extensionreference.go b/client-go/applyconfiguration/api/v1alpha2/extensionreference.go index 937e5795..4db2dae1 100644 --- a/client-go/applyconfiguration/api/v1alpha2/extensionreference.go +++ b/client-go/applyconfiguration/api/v1alpha2/extensionreference.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go b/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go index 1fbfe106..8c810170 100644 --- a/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go +++ b/client-go/applyconfiguration/api/v1alpha2/inferencemodel.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go b/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go index 438ccd48..f9b453a4 100644 --- a/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go +++ b/client-go/applyconfiguration/api/v1alpha2/inferencemodelspec.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go b/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go index e8142efe..4c9e10a9 100644 --- a/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go +++ b/client-go/applyconfiguration/api/v1alpha2/inferencemodelstatus.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencepool.go b/client-go/applyconfiguration/api/v1alpha2/inferencepool.go index cd725cb6..15649a60 100644 --- a/client-go/applyconfiguration/api/v1alpha2/inferencepool.go +++ b/client-go/applyconfiguration/api/v1alpha2/inferencepool.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go b/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go index e4d5a97d..ba0fe3c3 100644 --- a/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go +++ b/client-go/applyconfiguration/api/v1alpha2/inferencepoolspec.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go b/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go index 9587dabe..daf3be20 100644 --- a/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go +++ b/client-go/applyconfiguration/api/v1alpha2/inferencepoolstatus.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go b/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go index 20abf6b2..7227560e 100644 --- a/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go +++ b/client-go/applyconfiguration/api/v1alpha2/poolobjectreference.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/poolstatus.go b/client-go/applyconfiguration/api/v1alpha2/poolstatus.go index bff29935..9d7d7294 100644 --- a/client-go/applyconfiguration/api/v1alpha2/poolstatus.go +++ b/client-go/applyconfiguration/api/v1alpha2/poolstatus.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/api/v1alpha2/targetmodel.go b/client-go/applyconfiguration/api/v1alpha2/targetmodel.go index 4ed9b4bc..1c9277fa 100644 --- a/client-go/applyconfiguration/api/v1alpha2/targetmodel.go +++ b/client-go/applyconfiguration/api/v1alpha2/targetmodel.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/internal/internal.go b/client-go/applyconfiguration/internal/internal.go index 756160bd..e1bbb864 100644 --- a/client-go/applyconfiguration/internal/internal.go +++ b/client-go/applyconfiguration/internal/internal.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/applyconfiguration/utils.go b/client-go/applyconfiguration/utils.go index e1ad5ea4..cec3969a 100644 --- a/client-go/applyconfiguration/utils.go +++ b/client-go/applyconfiguration/utils.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/clientset.go b/client-go/clientset/versioned/clientset.go index c56d11c7..9ed7187b 100644 --- a/client-go/clientset/versioned/clientset.go +++ b/client-go/clientset/versioned/clientset.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/fake/clientset_generated.go b/client-go/clientset/versioned/fake/clientset_generated.go index b0ecd50b..f2f42110 100644 --- a/client-go/clientset/versioned/fake/clientset_generated.go +++ b/client-go/clientset/versioned/fake/clientset_generated.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/fake/doc.go b/client-go/clientset/versioned/fake/doc.go index 634bd02c..0f3cdf28 100644 --- a/client-go/clientset/versioned/fake/doc.go +++ b/client-go/clientset/versioned/fake/doc.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/fake/register.go b/client-go/clientset/versioned/fake/register.go index 365ccb75..0966faea 100644 --- a/client-go/clientset/versioned/fake/register.go +++ b/client-go/clientset/versioned/fake/register.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/scheme/doc.go b/client-go/clientset/versioned/scheme/doc.go index 40e42c29..a3e95ed2 100644 --- a/client-go/clientset/versioned/scheme/doc.go +++ b/client-go/clientset/versioned/scheme/doc.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/scheme/register.go b/client-go/clientset/versioned/scheme/register.go index b656f121..1e4975e5 100644 --- a/client-go/clientset/versioned/scheme/register.go +++ b/client-go/clientset/versioned/scheme/register.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go b/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go index b011ca92..16c14453 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/api_client.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/doc.go b/client-go/clientset/versioned/typed/api/v1alpha2/doc.go index 2bcba220..0240168e 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/doc.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/doc.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go index fbfccbb9..01839331 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/doc.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go index 0296608c..5bd7fd40 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_api_client.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go index 2492a557..50f78c52 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencemodel.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go index 64b087dd..a7f6a185 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/fake/fake_inferencepool.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go b/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go index 399789d8..1b9be99f 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/generated_expansion.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go b/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go index ee0d92c1..c5fb5c3d 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/inferencemodel.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go b/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go index 8482451e..6cbfb546 100644 --- a/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go +++ b/client-go/clientset/versioned/typed/api/v1alpha2/inferencepool.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/api/interface.go b/client-go/informers/externalversions/api/interface.go index 10eef397..572f5230 100644 --- a/client-go/informers/externalversions/api/interface.go +++ b/client-go/informers/externalversions/api/interface.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go b/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go index 74f640d1..d21f9cda 100644 --- a/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go +++ b/client-go/informers/externalversions/api/v1alpha2/inferencemodel.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/api/v1alpha2/inferencepool.go b/client-go/informers/externalversions/api/v1alpha2/inferencepool.go index d04591dd..4d042db7 100644 --- a/client-go/informers/externalversions/api/v1alpha2/inferencepool.go +++ b/client-go/informers/externalversions/api/v1alpha2/inferencepool.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/api/v1alpha2/interface.go b/client-go/informers/externalversions/api/v1alpha2/interface.go index 9e5c4d9c..6db5619e 100644 --- a/client-go/informers/externalversions/api/v1alpha2/interface.go +++ b/client-go/informers/externalversions/api/v1alpha2/interface.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/factory.go b/client-go/informers/externalversions/factory.go index c06ea464..9b52e814 100644 --- a/client-go/informers/externalversions/factory.go +++ b/client-go/informers/externalversions/factory.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/generic.go b/client-go/informers/externalversions/generic.go index 4186b2f6..143f9289 100644 --- a/client-go/informers/externalversions/generic.go +++ b/client-go/informers/externalversions/generic.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go b/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go index 5b70862a..b11099a0 100644 --- a/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go +++ b/client-go/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/listers/api/v1alpha2/expansion_generated.go b/client-go/listers/api/v1alpha2/expansion_generated.go index 204c375b..6abe0b37 100644 --- a/client-go/listers/api/v1alpha2/expansion_generated.go +++ b/client-go/listers/api/v1alpha2/expansion_generated.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/listers/api/v1alpha2/inferencemodel.go b/client-go/listers/api/v1alpha2/inferencemodel.go index ce83b85f..22ca6a16 100644 --- a/client-go/listers/api/v1alpha2/inferencemodel.go +++ b/client-go/listers/api/v1alpha2/inferencemodel.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/client-go/listers/api/v1alpha2/inferencepool.go b/client-go/listers/api/v1alpha2/inferencepool.go index c7e49a1e..48879560 100644 --- a/client-go/listers/api/v1alpha2/inferencepool.go +++ b/client-go/listers/api/v1alpha2/inferencepool.go @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 4ad43857..8057371b 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2024 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 6b1fbfdeeec75d277d0a7466cbd3dfe6a0e8fb49 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 24 Mar 2025 15:26:33 -0700 Subject: [PATCH 147/260] Allow partial metric updates (#561) --- pkg/epp/backend/metrics/metrics.go | 3 ++- pkg/epp/backend/metrics/pod_metrics.go | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pkg/epp/backend/metrics/metrics.go b/pkg/epp/backend/metrics/metrics.go index be732e78..d48b1dc5 100644 --- a/pkg/epp/backend/metrics/metrics.go +++ b/pkg/epp/backend/metrics/metrics.go @@ -39,7 +39,8 @@ type PodMetricsClientImpl struct { MetricMapping *MetricMapping } -// FetchMetrics fetches metrics from a given pod. +// FetchMetrics fetches metrics from a given pod, clones the existing metrics object and returns an +// updated one. func (p *PodMetricsClientImpl) FetchMetrics( ctx context.Context, pod *Pod, diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index 01db14be..b7f20e9b 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -116,16 +116,21 @@ func (pm *podMetrics) refreshMetrics() error { updated, err := pm.pmc.FetchMetrics(ctx, pm.GetPod(), pm.GetMetrics(), pool.Spec.TargetPortNumber) if err != nil { pm.logger.V(logutil.TRACE).Info("Failed to refreshed metrics:", "err", err) - // As refresher is running in the background, it's possible that the pod is deleted but - // the refresh goroutine doesn't read the done channel yet. In this case, we just return nil. - // The refresher will be stopped after this interval. - return nil } - updated.UpdateTime = time.Now() - - pm.logger.V(logutil.TRACE).Info("Refreshed metrics", "updated", updated) + // Optimistically update metrics even if there was an error. + // The FetchMetrics can return an error for the following reasons: + // 1. As refresher is running in the background, it's possible that the pod is deleted but + // the refresh goroutine doesn't read the done channel yet. In this case, the updated + // metrics object will be nil. And the refresher will soon be stopped. + // 2. The FetchMetrics call can partially fail. For example, due to one metric missing. In + // this case, the updated metrics object will have partial updates. A partial update is + // considered better than no updates. + if updated != nil { + updated.UpdateTime = time.Now() + pm.logger.V(logutil.TRACE).Info("Refreshed metrics", "updated", updated) + atomic.StorePointer(&pm.metrics, unsafe.Pointer(updated)) + } - atomic.StorePointer(&pm.metrics, unsafe.Pointer(updated)) return nil } From 752274ffc2774c36be5df493316c3da68e3853a2 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Mon, 24 Mar 2025 17:50:33 -0700 Subject: [PATCH 148/260] removing unsafe lib by switching to atomic.Pointer (#567) --- pkg/epp/backend/metrics/pod_metrics.go | 13 ++++++------- pkg/epp/backend/metrics/types.go | 6 +++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index b7f20e9b..cfb6b138 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -22,7 +22,6 @@ import ( "sync" "sync/atomic" "time" - "unsafe" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" @@ -36,8 +35,8 @@ const ( ) type podMetrics struct { - pod unsafe.Pointer // stores a *Pod - metrics unsafe.Pointer // stores a *Metrics + pod atomic.Pointer[Pod] + metrics atomic.Pointer[Metrics] pmc PodMetricsClient ds Datastore interval time.Duration @@ -58,15 +57,15 @@ func (pm *podMetrics) String() string { } func (pm *podMetrics) GetPod() *Pod { - return (*Pod)(atomic.LoadPointer(&pm.pod)) + return pm.pod.Load() } func (pm *podMetrics) GetMetrics() *Metrics { - return (*Metrics)(atomic.LoadPointer(&pm.metrics)) + return pm.metrics.Load() } func (pm *podMetrics) UpdatePod(in *corev1.Pod) { - atomic.StorePointer(&pm.pod, unsafe.Pointer(toInternalPod(in))) + pm.pod.Store(toInternalPod(in)) } func toInternalPod(in *corev1.Pod) *Pod { @@ -128,7 +127,7 @@ func (pm *podMetrics) refreshMetrics() error { if updated != nil { updated.UpdateTime = time.Now() pm.logger.V(logutil.TRACE).Info("Refreshed metrics", "updated", updated) - atomic.StorePointer(&pm.metrics, unsafe.Pointer(updated)) + pm.metrics.Store(updated) } return nil diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index fd600163..17db23b4 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -22,7 +22,6 @@ import ( "fmt" "sync" "time" - "unsafe" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" @@ -43,8 +42,6 @@ type PodMetricsFactory struct { func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1.Pod, ds Datastore) PodMetrics { pm := &podMetrics{ - pod: unsafe.Pointer(toInternalPod(in)), - metrics: unsafe.Pointer(newMetrics()), pmc: f.pmc, ds: ds, interval: f.refreshMetricsInterval, @@ -53,6 +50,9 @@ func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1. done: make(chan struct{}), logger: log.FromContext(parentCtx), } + pm.pod.Store(toInternalPod(in)) + pm.metrics.Store(newMetrics()) + pm.startRefreshLoop() return pm } From 383bfaf5c091a45719ccb87873e2fa29af026025 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 18:34:32 -0700 Subject: [PATCH 149/260] Bump google.golang.org/protobuf from 1.36.5 to 1.36.6 (#568) Bumps google.golang.org/protobuf from 1.36.5 to 1.36.6. --- updated-dependencies: - dependency-name: google.golang.org/protobuf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 49b5608e..1e1eb03d 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + google.golang.org/protobuf v1.36.6 k8s.io/api v0.32.3 k8s.io/apiextensions-apiserver v0.32.3 k8s.io/apimachinery v0.32.3 @@ -26,6 +26,7 @@ require ( k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.3 sigs.k8s.io/structured-merge-diff/v4 v4.6.0 + sigs.k8s.io/yaml v1.4.0 ) require ( @@ -129,5 +130,4 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect sigs.k8s.io/controller-tools v0.14.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 816a5525..dc10f0a2 100644 --- a/go.sum +++ b/go.sum @@ -279,8 +279,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From c938aa28fd9c3e804854d231d3797d47b9339946 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 07:42:34 -0700 Subject: [PATCH 150/260] Bump github.com/onsi/gomega from 1.36.2 to 1.36.3 (#569) Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.36.2 to 1.36.3. - [Release notes](https://github.com/onsi/gomega/releases) - [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md) - [Commits](https://github.com/onsi/gomega/compare/v1.36.2...v1.36.3) --- updated-dependencies: - dependency-name: github.com/onsi/gomega dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 1e1eb03d..bb993d1d 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,8 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.7.0 - github.com/onsi/ginkgo/v2 v2.23.0 - github.com/onsi/gomega v1.36.2 + github.com/onsi/ginkgo/v2 v2.23.3 + github.com/onsi/gomega v1.36.3 github.com/prometheus/client_golang v1.21.1 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.63.0 @@ -104,15 +104,15 @@ require ( go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/crypto v0.35.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.23.0 // indirect - golang.org/x/net v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/term v0.29.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.30.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect diff --git a/go.sum b/go.sum index dc10f0a2..6888ab1c 100644 --- a/go.sum +++ b/go.sum @@ -151,10 +151,10 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ= -github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= +github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= +github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= +github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -222,8 +222,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -234,29 +234,29 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 3774251bfc311e5054923e3e704e7ac875e1e87b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:28:33 -0700 Subject: [PATCH 151/260] Bump sigs.k8s.io/controller-runtime from 0.20.3 to 0.20.4 (#570) Bumps [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) from 0.20.3 to 0.20.4. - [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases) - [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.20.3...v0.20.4) --- updated-dependencies: - dependency-name: sigs.k8s.io/controller-runtime dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index bb993d1d..fba85f91 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( k8s.io/code-generator v0.32.3 k8s.io/component-base v0.32.3 k8s.io/utils v0.0.0-20241210054802-24370beab758 - sigs.k8s.io/controller-runtime v0.20.3 + sigs.k8s.io/controller-runtime v0.20.4 sigs.k8s.io/structured-merge-diff/v4 v4.6.0 sigs.k8s.io/yaml v1.4.0 ) diff --git a/go.sum b/go.sum index 6888ab1c..2bcff108 100644 --- a/go.sum +++ b/go.sum @@ -320,8 +320,8 @@ k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJ k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.20.3 h1:I6Ln8JfQjHH7JbtCD2HCYHoIzajoRxPNuvhvcDbZgkI= -sigs.k8s.io/controller-runtime v0.20.3/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= +sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= sigs.k8s.io/controller-tools v0.14.0 h1:rnNoCC5wSXlrNoBKKzL70LNJKIQKEzT6lloG6/LF73A= sigs.k8s.io/controller-tools v0.14.0/go.mod h1:TV7uOtNNnnR72SpzhStvPkoS/U5ir0nMudrkrC4M9Sc= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= From 731f2445e765eca29a215d960baf15337e6fe8a5 Mon Sep 17 00:00:00 2001 From: Clayton Coleman Date: Tue, 25 Mar 2025 12:04:33 -0400 Subject: [PATCH 152/260] Configure the vllm deployment with best practices for startup (#550) We want to recommend best practices for deployments of model servers under an InferencePool. Use the need to gracefully drain without client visible errors during rollout ("hitless" updates) to annotate the yaml with strong opinions on best practices. This configuration was experimentally verified on the GKE Inference Gateway configuration which should be longer than other servers. --- config/manifests/vllm/gpu-deployment.yaml | 146 ++++++++++++++++++++-- 1 file changed, 138 insertions(+), 8 deletions(-) diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index cdc4d82c..ecff81ec 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -46,26 +46,93 @@ spec: - containerPort: 8000 name: http protocol: TCP + lifecycle: + preStop: + # vLLM stops accepting connections when it receives SIGTERM, so we need to sleep + # to give upstream gateways a chance to take us out of rotation. The time we wait + # is dependent on the time it takes for all upstreams to completely remove us from + # rotation. Older or simpler load balancers might take upwards of 30s, but we expect + # our deployment to run behind a modern gateway like Envoy which is designed to + # probe for readiness aggressively. + sleep: + # Upstream gateway probers for health should be set on a low period, such as 5s, + # and the shorter we can tighten that bound the faster that we release + # accelerators during controlled shutdowns. However, we should expect variance, + # as load balancers may have internal delays, and we don't want to drop requests + # normally, so we're often aiming to set this value to a p99 propagation latency + # of readiness -> load balancer taking backend out of rotation, not the average. + # + # This value is generally stable and must often be experimentally determined on + # for a given load balancer and health check period. We set the value here to + # the highest value we observe on a supported load balancer, and we recommend + # tuning this value down and verifying no requests are dropped. + # + # If this value is updated, be sure to update terminationGracePeriodSeconds. + # + seconds: 30 + # + # IMPORTANT: preStop.sleep is beta as of Kubernetes 1.30 - for older versions + # replace with this exec action. + #exec: + # command: + # - /usr/bin/sleep + # - 30 livenessProbe: - failureThreshold: 240 httpGet: path: /health port: http scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 5 + # vLLM's health check is simple, so we can more aggressively probe it. Liveness + # check endpoints should always be suitable for aggressive probing. + periodSeconds: 1 successThreshold: 1 + # vLLM has a very simple health implementation, which means that any failure is + # likely significant. However, any liveness triggered restart requires the very + # large core model to be reloaded, and so we should bias towards ensuring the + # server is definitely unhealthy vs immediately restarting. Use 5 attempts as + # evidence of a serious problem. + failureThreshold: 5 timeoutSeconds: 1 readinessProbe: - failureThreshold: 600 httpGet: path: /health port: http scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 5 + # vLLM's health check is simple, so we can more aggressively probe it. Readiness + # check endpoints should always be suitable for aggressive probing, but may be + # slightly more expensive than readiness probes. + periodSeconds: 1 successThreshold: 1 + # vLLM has a very simple health implementation, which means that any failure is + # likely significant, + failureThreshold: 1 timeoutSeconds: 1 + # We set a startup probe so that we don't begin directing traffic or checking + # liveness to this instance until the model is loaded. + startupProbe: + # Failure threshold is when we believe startup will not happen at all, and is set + # to the maximum possible time we believe loading a model will take. In our + # default configuration we are downloading a model from HuggingFace, which may + # take a long time, then the model must load into the accelerator. We choose + # 10 minutes as a reasonable maximum startup time before giving up and attempting + # to restart the pod. + # + # IMPORTANT: If the core model takes more than 10 minutes to load, pods will crash + # loop forever. Be sure to set this appropriately. + failureThreshold: 600 + # Set delay to start low so that if the base model changes to something smaller + # or an optimization is deployed, we don't wait unneccesarily. + initialDelaySeconds: 2 + # As a startup probe, this stops running and so we can more aggressively probe + # even a moderately complex startup - this is a very important workload. + periodSeconds: 1 + httpGet: + # vLLM does not start the OpenAI server (and hence make /health available) + # until models are loaded. This may not be true for all model servers. + path: /health + port: http + scheme: HTTP + resources: limits: nvidia.com/gpu: 1 @@ -92,8 +159,71 @@ spec: - name: config-volume mountPath: /config restartPolicy: Always - schedulerName: default-scheduler - terminationGracePeriodSeconds: 30 + + # vLLM allows VLLM_PORT to be specified as an environment variable, but a user might + # create a 'vllm' service in their namespace. That auto-injects VLLM_PORT in docker + # compatible form as `tcp://:` instead of the numeric value vLLM accepts + # causing CrashLoopBackoff. Set service environment injection off by default. + enableServiceLinks: false + + # Generally, the termination grace period needs to last longer than the slowest request + # we expect to serve plus any extra time spent waiting for load balancers to take the + # model server out of rotation. + # + # An easy starting point is the p99 or max request latency measured for your workload, + # although LLM request latencies vary significantly if clients send longer inputs or + # trigger longer outputs. Since steady state p99 will be higher than the latency + # to drain a server, you may wish to slightly this value either experimentally or + # via the calculation below. + # + # For most models you can derive an upper bound for the maximum drain latency as + # follows: + # + # 1. Identify the maximum context length the model was trained on, or the maximum + # allowed length of output tokens configured on vLLM (llama2-7b was trained to + # 4k context length, while llama3-8b was trained to 128k). + # 2. Output tokens are the more compute intensive to calculate and the accelerator + # will have a maximum concurrency (batch size) - the time per output token at + # maximum batch with no prompt tokens being processed is the slowest an output + # token can be generated (for this model it would be about 100ms TPOT at a max + # batch size around 50) + # 3. Calculate the worst case request duration if a request starts immediately + # before the server stops accepting new connections - generally when it receives + # SIGTERM (for this model that is about 4096 / 10 ~ 40s) + # 4. If there are any requests generating prompt tokens that will delay when those + # output tokens start, and prompt token generation is roughly 6x faster than + # compute-bound output token generation, so add 20% to the time from above (40s + + # 16s ~ 55s) + # + # Thus we think it will take us at worst about 55s to complete the longest possible + # request the model is likely to receive at maximum concurrency (highest latency) + # once requests stop being sent. + # + # NOTE: This number will be lower than steady state p99 latency since we stop receiving + # new requests which require continuous prompt token computation. + # NOTE: The max timeout for backend connections from gateway to model servers should + # be configured based on steady state p99 latency, not drain p99 latency + # + # 5. Add the time the pod takes in its preStop hook to allow the load balancers have + # stopped sending us new requests (55s + 30s ~ 85s) + # + # Because termination grace period controls when the Kubelet forcibly terminates a + # stuck or hung process (a possibility due to a GPU crash), there is operational safety + # in keeping the value roughly proportional to the time to finish serving. There is also + # value in adding a bit of extra time to deal with unexpectedly long workloads. + # + # 6. Add a 50% safety buffer to this time since the operational impact should be low + # (85s * 1.5 ~ 130s) + # + # One additional source of drain latency is that some workloads may run close to + # saturation and have queued requests on each server. Since traffic in excess of the + # max sustainable QPS will result in timeouts as the queues grow, we assume that failure + # to drain in time due to excess queues at the time of shutdown is an expected failure + # mode of server overload. If your workload occasionally experiences high queue depths + # due to periodic traffic, consider increasing the safety margin above to account for + # time to drain queued requests. + terminationGracePeriodSeconds: 130 + volumes: - name: data emptyDir: {} From b7d35b65718890f30b73f131c1499a9716de3517 Mon Sep 17 00:00:00 2001 From: Clayton Coleman Date: Tue, 25 Mar 2025 14:56:33 -0400 Subject: [PATCH 153/260] Configure gpu-deployment.yaml to force vLLM v1 with LoRA (#573) Until 0.8.3 is released, using the LoRA flag disables automatic v1 opt-in. --- config/manifests/vllm/gpu-deployment.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index ecff81ec..e9507601 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -33,6 +33,10 @@ spec: - '{"name": "tweet-summary-0", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' - '{"name": "tweet-summary-1", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' env: + # Enabling LoRA support temporarily disables automatic v1, we want to force it on + # until 0.8.3 vLLM is released. + - name: VLLM_USE_V1 + value: "1" - name: PORT value: "8000" - name: HUGGING_FACE_HUB_TOKEN From 83261103905893bfe3baaad206c84b87819bdb2c Mon Sep 17 00:00:00 2001 From: Abdullah Gharaibeh <40361897+ahg-g@users.noreply.github.com> Date: Thu, 27 Mar 2025 00:58:39 +0000 Subject: [PATCH 154/260] Cleanup logging in the request scheduling path (#583) --- pkg/epp/backend/metrics/logger.go | 4 +- pkg/epp/handlers/response.go | 6 +-- pkg/epp/handlers/streamingserver.go | 71 ++++++++++++----------------- pkg/epp/scheduling/scheduler.go | 4 +- 4 files changed, 35 insertions(+), 50 deletions(-) diff --git a/pkg/epp/backend/metrics/logger.go b/pkg/epp/backend/metrics/logger.go index 74735755..8c73d488 100644 --- a/pkg/epp/backend/metrics/logger.go +++ b/pkg/epp/backend/metrics/logger.go @@ -78,7 +78,7 @@ func StartMetricsLogger(ctx context.Context, datastore Datastore, refreshPrometh return time.Since(pm.GetMetrics().UpdateTime) > metricsValidityPeriod }) s := fmt.Sprintf("Current Pods and metrics gathered. Fresh metrics: %+v, Stale metrics: %+v", podsWithFreshMetrics, podsWithStaleMetrics) - logger.Info(s) + logger.V(logutil.VERBOSE).Info(s) } } }() @@ -89,7 +89,7 @@ func flushPrometheusMetricsOnce(logger logr.Logger, datastore Datastore) { pool, err := datastore.PoolGet() if err != nil { // No inference pool or not initialize. - logger.V(logutil.VERBOSE).Info("pool is not initialized, skipping flushing metrics") + logger.V(logutil.DEFAULT).Info("pool is not initialized, skipping flushing metrics") return } diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index 79ad7a6a..cf64f4a4 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -202,7 +202,7 @@ func (s *Server) HandleStreaming( ) error { responseText := string(body.ResponseBody.Body) if strings.Contains(responseText, streamingEndMsg) { - parsedResp := ParseRespForUsage(ctx, responseText, loggerVerbose) + parsedResp := ParseRespForUsage(ctx, responseText) reqCtx.Usage = parsedResp.Usage } @@ -230,7 +230,6 @@ func (s *Server) HandleStreaming( func ParseRespForUsage( ctx context.Context, responseText string, - loggerVerbose logr.Logger, ) Response { response := Response{} @@ -246,7 +245,8 @@ func ParseRespForUsage( byteSlice := []byte(content) if err := json.Unmarshal(byteSlice, &response); err != nil { - loggerVerbose.Error(err, "unmarshaling response body") + logger := log.FromContext(ctx) + logger.V(logutil.DEFAULT).Error(err, "unmarshaling response body") continue } } diff --git a/pkg/epp/handlers/streamingserver.go b/pkg/epp/handlers/streamingserver.go index 64f9c03b..d704578a 100644 --- a/pkg/epp/handlers/streamingserver.go +++ b/pkg/epp/handlers/streamingserver.go @@ -65,8 +65,8 @@ type StreamingServer struct { func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) error { ctx := srv.Context() logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Processing") + loggerTrace := logger.V(logutil.TRACE) + loggerTrace.Info("Processing") // Create request context to share states during life time of an HTTP request. // See https://github.com/envoyproxy/envoy/issues/17540. @@ -103,7 +103,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) if recvErr != nil { // This error occurs very frequently, though it doesn't seem to have any impact. // TODO Figure out if we can remove this noise. - loggerVerbose.Error(err, "Cannot receive stream request") + logger.V(logutil.DEFAULT).Error(err, "Cannot receive stream request") return status.Errorf(codes.Unknown, "cannot receive stream request: %v", err) } @@ -111,13 +111,13 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) case *extProcPb.ProcessingRequest_RequestHeaders: err = s.HandleRequestHeaders(ctx, reqCtx, v) case *extProcPb.ProcessingRequest_RequestBody: - loggerVerbose.Info("Incoming body chunk", "body", string(v.RequestBody.Body), "EoS", v.RequestBody.EndOfStream) + loggerTrace.Info("Incoming body chunk", "EoS", v.RequestBody.EndOfStream) // In the stream case, we can receive multiple request bodies. body = append(body, v.RequestBody.Body...) // Message is buffered, we can read and decode. if v.RequestBody.EndOfStream { - loggerVerbose.Info("decoding") + loggerTrace.Info("decoding") err = json.Unmarshal(body, &requestBody) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") @@ -133,22 +133,19 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) } - loggerVerbose.Info("Request context after HandleRequestBody", "context", reqCtx) } case *extProcPb.ProcessingRequest_RequestTrailers: // This is currently unused. case *extProcPb.ProcessingRequest_ResponseHeaders: - loggerVerbose.Info("got response headers", "headers", v.ResponseHeaders.Headers.GetHeaders()) for _, header := range v.ResponseHeaders.Headers.GetHeaders() { value := string(header.RawValue) - logger.V(logutil.TRACE).Info("header", "key", header.Key, "value", value) + loggerTrace.Info("header", "key", header.Key, "value", value) if header.Key == "status" && value != "200" { reqCtx.ResponseStatusCode = errutil.ModelServerError } else if header.Key == "content-type" && strings.Contains(value, "text/event-stream") { reqCtx.modelServerStreaming = true - loggerVerbose.Info("model server is streaming response") - logger.Error(nil, "made it here") + loggerTrace.Info("model server is streaming response") } } reqCtx.RequestState = ResponseRecieved @@ -179,7 +176,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) responseText := string(v.ResponseBody.Body) s.HandleResponseBodyModelStreaming(ctx, reqCtx, responseText) if v.ResponseBody.EndOfStream { - loggerVerbose.Info("streaming is completed") + loggerTrace.Info("stream completed") reqCtx.ResponseCompleteTimestamp = time.Now() metrics.RecordRequestLatencies(ctx, reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestReceivedTimestamp, reqCtx.ResponseCompleteTimestamp) @@ -207,6 +204,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // Message is buffered, we can read and decode. if v.ResponseBody.EndOfStream { + loggerTrace.Info("stream completed") // Don't send a 500 on a response error. Just let the message passthrough and log our error for debugging purposes. // We assume the body is valid JSON, err messages are not guaranteed to be json, and so capturing and sending a 500 obfuscates the response message. // using the standard 'err' var will send an immediate error response back to the caller. @@ -226,7 +224,6 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.Usage.CompletionTokens) } - loggerVerbose.Info("Request context after HandleResponseBody", "context", reqCtx) } } case *extProcPb.ProcessingRequest_ResponseTrailers: @@ -246,8 +243,8 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) } return nil } - loggerVerbose.Info("checking", "request state", reqCtx.RequestState) - if err := reqCtx.updateStateAndSendIfNeeded(srv, loggerVerbose); err != nil { + loggerTrace.Info("checking", "request state", reqCtx.RequestState) + if err := reqCtx.updateStateAndSendIfNeeded(srv, logger); err != nil { return err } } @@ -255,18 +252,19 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) // updateStateAndSendIfNeeded checks state and can send mutiple responses in a single pass, but only if ordered properly. // Order of requests matter in FULL_DUPLEX_STREAMING. For both request and response, the order of response sent back MUST be: Header->Body->Trailer, with trailer being optional. -func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, loggerVerbose logr.Logger) error { +func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProcessor_ProcessServer, logger logr.Logger) error { + loggerTrace := logger.V(logutil.TRACE) // No switch statement as we could send multiple responses in one pass. if r.RequestState == RequestReceived && r.reqHeaderResp != nil { - loggerVerbose.Info("Request header response", "obj", r.reqHeaderResp) + loggerTrace.Info("Sending request header response", "obj", r.reqHeaderResp) if err := srv.Send(r.reqHeaderResp); err != nil { - loggerVerbose.Error(err, "error sending response") + logger.V(logutil.DEFAULT).Error(err, "error sending response") return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } r.RequestState = HeaderRequestResponseComplete } if r.RequestState == HeaderRequestResponseComplete && r.reqBodyResp != nil { - loggerVerbose.Info("Request body response", "obj", r.reqBodyResp) + loggerTrace.Info("Sending request body response") if err := srv.Send(r.reqBodyResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } @@ -281,14 +279,14 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces } } if r.RequestState == ResponseRecieved && r.respHeaderResp != nil { - loggerVerbose.Info("Response header response", "obj", r.respHeaderResp) + loggerTrace.Info("Sending response header response", "obj", r.respHeaderResp) if err := srv.Send(r.respHeaderResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } r.RequestState = HeaderResponseResponseComplete } if r.RequestState == HeaderResponseResponseComplete && r.respBodyResp != nil { - loggerVerbose.Info("Response body response", "obj", r.respBodyResp) + loggerTrace.Info("Sending response body response") if err := srv.Send(r.respBodyResp); err != nil { return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) } @@ -298,7 +296,7 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces r.RequestState = BodyResponseResponsesComplete } // Dump the response so a new stream message can begin - r.reqBodyResp = nil + r.respBodyResp = nil } if r.RequestState == BodyResponseResponsesComplete && r.respTrailerResp != nil { // Trailers in requests are not guaranteed @@ -318,15 +316,13 @@ func (s *StreamingServer) HandleRequestBody( ) (*RequestContext, error) { var requestBodyBytes []byte logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Handling request body") // Resolve target models. model, ok := requestBodyMap["model"].(string) if !ok { return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} } - loggerVerbose.Info("Model requested", "model", model) + modelName := model // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. @@ -347,7 +343,7 @@ func (s *StreamingServer) HandleRequestBody( ResolvedTargetModel: modelName, Critical: datastore.IsCritical(modelObj), } - loggerVerbose.Info("LLM request assembled", "request", llmReq) + logger.V(logutil.DEBUG).Info("LLM request assembled", "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "critical", llmReq.Critical) var err error // Update target models in the body. @@ -360,7 +356,6 @@ func (s *StreamingServer) HandleRequestBody( logger.V(logutil.DEFAULT).Error(err, "Error marshaling request body") return reqCtx, errutil.Error{Code: errutil.Internal, Msg: fmt.Sprintf("error marshaling request body: %v", err)} } - loggerVerbose.Info("Updated request body marshalled", "body", string(requestBodyBytes)) target, err := s.scheduler.Schedule(ctx, llmReq) if err != nil { @@ -377,7 +372,8 @@ func (s *StreamingServer) HandleRequestBody( endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) logger.V(logutil.DEFAULT).Info("Request handled", - "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod) + "model", llmReq.Model, "targetModel", llmReq.ResolvedTargetModel, "endpoint", targetPod, "endpoint metrics", + fmt.Sprintf("%+v", target)) reqCtx.Model = llmReq.Model reqCtx.ResolvedTargetModel = llmReq.ResolvedTargetModel @@ -385,7 +381,7 @@ func (s *StreamingServer) HandleRequestBody( reqCtx.TargetPod = targetPod.NamespacedName.String() reqCtx.TargetEndpoint = endpoint - s.populateRequestHeaderResponse(ctx, reqCtx, endpoint, len(requestBodyBytes)) + s.populateRequestHeaderResponse(reqCtx, endpoint, len(requestBodyBytes)) reqCtx.reqBodyResp = &extProcPb.ProcessingResponse{ // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header @@ -416,8 +412,6 @@ func (s *StreamingServer) HandleResponseBody( response map[string]interface{}, ) (*RequestContext, error) { logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Processing HandleResponseBody") responseBytes, err := json.Marshal(response) if err != nil { logger.V(logutil.DEFAULT).Error(err, "error marshalling responseBody") @@ -431,7 +425,7 @@ func (s *StreamingServer) HandleResponseBody( TotalTokens: int(usg["total_tokens"].(float64)), } reqCtx.Usage = usage - loggerVerbose.Info("Response generated", "usage", reqCtx.Usage) + logger.V(logutil.VERBOSE).Info("Response generated", "usage", reqCtx.Usage) } reqCtx.ResponseSize = len(responseBytes) // ResponseComplete is to indicate the response is complete. In non-streaming @@ -469,12 +463,8 @@ func (s *StreamingServer) HandleResponseBodyModelStreaming( reqCtx *RequestContext, responseText string, ) { - logger := log.FromContext(ctx) - loggerVerbose := logger.V(logutil.VERBOSE) - loggerVerbose.Info("Processing HandleResponseBody") - if strings.Contains(responseText, streamingEndMsg) { - resp := ParseRespForUsage(ctx, responseText, loggerVerbose) + resp := ParseRespForUsage(ctx, responseText) metrics.RecordInputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.PromptTokens) metrics.RecordOutputTokens(reqCtx.Model, reqCtx.ResolvedTargetModel, resp.Usage.CompletionTokens) } @@ -495,13 +485,12 @@ func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *Requ return err } endpoint := pod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) - s.populateRequestHeaderResponse(ctx, reqCtx, endpoint, 0) + s.populateRequestHeaderResponse(reqCtx, endpoint, 0) } return nil } -func (s *StreamingServer) populateRequestHeaderResponse(ctx context.Context, reqCtx *RequestContext, endpoint string, requestBodyLength int) { - logger := log.FromContext(ctx) +func (s *StreamingServer) populateRequestHeaderResponse(reqCtx *RequestContext, endpoint string, requestBodyLength int) { headers := []*configPb.HeaderValueOption{ { Header: &configPb.HeaderValue{ @@ -520,10 +509,6 @@ func (s *StreamingServer) populateRequestHeaderResponse(ctx context.Context, req }, }) } - // Print headers for debugging - for _, header := range headers { - logger.V(logutil.DEBUG).Info("Request body header", "key", header.Header.Key, "value", header.Header.RawValue) - } targetEndpointValue := &structpb.Struct{ Fields: map[string]*structpb.Value{ diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index c861996a..63d829a1 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -125,13 +125,13 @@ func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod ba logger := log.FromContext(ctx).WithValues("request", req) podMetrics := s.datastore.PodGetAll() - logger.V(logutil.VERBOSE).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", podMetrics)) + logger.V(logutil.DEBUG).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", podMetrics)) pods, err := s.filter.Filter(logger, req, podMetrics) if err != nil || len(pods) == 0 { return nil, fmt.Errorf( "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } - logger.V(logutil.VERBOSE).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) + logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) i := rand.Intn(len(pods)) return pods[i], nil } From c2d2f881ab95211987cc0b3cdeb2444f49d906a6 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 27 Mar 2025 15:04:40 +0200 Subject: [PATCH 155/260] minor update to Makefile (#588) Signed-off-by: Nir Rozenbaum --- Makefile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 400ec07e..66fe89d4 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ IMAGE_REGISTRY ?= $(STAGING_IMAGE_REGISTRY)/gateway-api-inference-extension IMAGE_NAME := epp IMAGE_REPO ?= $(IMAGE_REGISTRY)/$(IMAGE_NAME) IMAGE_TAG ?= $(IMAGE_REPO):$(GIT_TAG) -ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) E2E_MANIFEST_PATH ?= config/manifests/vllm/gpu-deployment.yaml SYNCER_IMAGE_NAME := lora-syncer @@ -92,7 +92,6 @@ generate: controller-gen code-generator manifests ## Generate code containing De $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." ./hack/update-codegen.sh -PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) # Use same code-generator version as k8s.io/api CODEGEN_VERSION := $(shell go list -m -f '{{.Version}}' k8s.io/api) CODEGEN = $(shell pwd)/bin/code-generator @@ -130,7 +129,7 @@ test-integration: ## Run tests. .PHONY: test-e2e test-e2e: ## Run end-to-end tests against an existing Kubernetes cluster. When using default configuration, the tests need at least 3 available GPUs. - MANIFEST_PATH=$(ROOT_DIR)/$(E2E_MANIFEST_PATH) go test ./test/e2e/epp/ -v -ginkgo.v + MANIFEST_PATH=$(PROJECT_DIR)/$(E2E_MANIFEST_PATH) go test ./test/e2e/epp/ -v -ginkgo.v .PHONY: lint lint: golangci-lint ## Run golangci-lint linter From d1d11f8c8f9626872764d06fc93b44a63046fb52 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Thu, 27 Mar 2025 06:20:45 -0700 Subject: [PATCH 156/260] Adding printer columns to inference model (#574) --- api/v1alpha2/inferencemodel_types.go | 4 ++++ ...rence.networking.x-k8s.io_inferencemodels.yaml | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/api/v1alpha2/inferencemodel_types.go b/api/v1alpha2/inferencemodel_types.go index d80bd556..052683d8 100644 --- a/api/v1alpha2/inferencemodel_types.go +++ b/api/v1alpha2/inferencemodel_types.go @@ -25,6 +25,10 @@ import ( // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Model Name",type=string,JSONPath=`.spec.modelName` +// +kubebuilder:printcolumn:name="Inference Pool",type=string,JSONPath=`.spec.poolRef.name` +// +kubebuilder:printcolumn:name="Criticality",type=string,JSONPath=`.spec.criticality` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +genclient type InferenceModel struct { metav1.TypeMeta `json:",inline"` diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml index 63c7fb51..28805096 100644 --- a/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml +++ b/config/crd/bases/inference.networking.x-k8s.io_inferencemodels.yaml @@ -14,7 +14,20 @@ spec: singular: inferencemodel scope: Namespaced versions: - - name: v1alpha2 + - additionalPrinterColumns: + - jsonPath: .spec.modelName + name: Model Name + type: string + - jsonPath: .spec.poolRef.name + name: Inference Pool + type: string + - jsonPath: .spec.criticality + name: Criticality + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 schema: openAPIV3Schema: description: InferenceModel is the Schema for the InferenceModels API. From 2fed6caf50229ad0ee9ded24ae1c7e8fdb622bcb Mon Sep 17 00:00:00 2001 From: Rohit Ramkumar Date: Thu, 27 Mar 2025 11:38:40 -0400 Subject: [PATCH 157/260] Add provider-specific manifests for BBR helm chart (#585) --- config/charts/body-based-routing/README.md | 20 +++++--- .../body-based-routing/templates/gke.yaml | 49 +++++++++++++++++++ .../body-based-routing/templates/istio.yaml | 47 ++++++++++++++++++ config/charts/body-based-routing/values.yaml | 6 +++ 4 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 config/charts/body-based-routing/templates/gke.yaml create mode 100644 config/charts/body-based-routing/templates/istio.yaml diff --git a/config/charts/body-based-routing/README.md b/config/charts/body-based-routing/README.md index 4ef0c201..3c914dce 100644 --- a/config/charts/body-based-routing/README.md +++ b/config/charts/body-based-routing/README.md @@ -8,13 +8,20 @@ A chart to the body-based routing deployment and service. To install a body-based router named `body-based-router`, you can run the following command: ```txt -$ helm install body-based-router ./config/charts/body-based-routing +$ helm install body-based-router ./config/charts/body-based-routing \ + --set provider.name=[gke|istio] \ + --set inference-gateway.name=inference-gateway ``` +Note that the provider name is needed to ensure provider-specific manifests are also applied. If no provider is specified, then only +the deployment and service are deployed. + To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: ```txt -$ helm install body-based-router oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/body-based-router --version v0 +$ helm install body-based-router oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/body-based-router \ + --version v0 + --set provider.name=[gke|istio] ``` ## Uninstall @@ -37,12 +44,9 @@ The following table list the configurable parameters of the chart. | `bbr.image.hub` | Registry URL where the image is hosted. | | `bbr.image.tag` | Image tag. | | `bbr.image.pullPolicy` | Image pull policy for the container. Possible values: `Always`, `IfNotPresent`, or `Never`. Defaults to `Always`. | +| `provider.name` | Name of the Inference Gateway implementation being used. Possible values: `istio`, `gke`. Defaults to `none`. | +| `inference-gateway.name` | The name of the Gateway. Defaults to `inference-gateway`. | ## Notes -This chart will only deploy the body-based router deployment and service. -Note that this should only be deployed once per Gateway. - -Additional configuration is needed to configure a proxy extension that calls -out to the service in the request path. For example, vwith Envoy Gateway, this -would require configuring EnvoyExtensionPolicy. +This chart should only be deployed once per Gateway. \ No newline at end of file diff --git a/config/charts/body-based-routing/templates/gke.yaml b/config/charts/body-based-routing/templates/gke.yaml new file mode 100644 index 00000000..db661bcf --- /dev/null +++ b/config/charts/body-based-routing/templates/gke.yaml @@ -0,0 +1,49 @@ +{{- if eq .Values.provider.name "gke" }} +--- +kind: GCPRoutingExtension +apiVersion: networking.gke.io/v1 +metadata: + name: {{ .Values.bbr.name }} + namespace: {{ .Release.Namespace }} +spec: + targetRefs: + - group: "gateway.networking.k8s.io" + kind: Gateway + name: {{ .Values.inference-gateway.name }} + extensionChains: + - name: chain1 + extensions: + - name: ext1 + authority: "myext.com" + timeout: 1s + supportedEvents: + - RequestHeaders + - RequestBody + - RequestTrailers + requestBodySendMode: "FullDuplexStreamed" + backendRef: + group: "" + kind: Service + name: {{ .Values.bbr.name }} + port: 9004 +--- +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: bbr-healthcheck + namespace: {{ .Release.Namespace }} +spec: + default: + logConfig: + enabled: true + config: + type: "GRPC" + grpcHealthCheck: + portSpecification: "USE_FIXED_PORT" + port: 9005 + targetRef: + group: "" + kind: Service + name: {{ .Values.bbr.name }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/config/charts/body-based-routing/templates/istio.yaml b/config/charts/body-based-routing/templates/istio.yaml new file mode 100644 index 00000000..0f9f5f11 --- /dev/null +++ b/config/charts/body-based-routing/templates/istio.yaml @@ -0,0 +1,47 @@ +{{- if eq .Values.provider.name "istio" }} +--- +apiVersion: networking.istio.io/v1alpha3 +kind: EnvoyFilter +metadata: + name: {{ .Values.bbr.name }} + namespace: {{ .Release.Namespace }} +spec: + configPatches: + - applyTo: HTTP_FILTER + match: + # context omitted so that this applies to both sidecars and gateways + listener: + filterChain: + filter: + name: "envoy.filters.network.http_connection_manager" + patch: + operation: INSERT_FIRST + value: + name: envoy.filters.http.ext_proc + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_proc.v3.ExternalProcessor + failure_mode_allow: false + allow_mode_override: true + processing_mode: + request_header_mode: "SEND" + response_header_mode: "SKIP" + request_body_mode: "BUFFERED" + response_body_mode: "NONE" + request_trailer_mode: "SKIP" + response_trailer_mode: "SKIP" + grpc_service: + envoy_grpc: + cluster_name: outbound|9004||{{ .Values.bbr.name }}.default.svc.cluster.local +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: + name: {{ .Values.bbr.name }} + namespace: {{ .Release.Namespace }} +spec: + host: {{ .Values.bbr.name }}.default.svc.cluster.local + trafficPolicy: + tls: + mode: SIMPLE + insecureSkipVerify: true +{{- end }} diff --git a/config/charts/body-based-routing/values.yaml b/config/charts/body-based-routing/values.yaml index b60f5d69..debd5f9e 100644 --- a/config/charts/body-based-routing/values.yaml +++ b/config/charts/body-based-routing/values.yaml @@ -7,3 +7,9 @@ bbr: tag: main pullPolicy: Always extProcPort: 9002 + +provider: + name: none + +inference-gateway: + name: inference-gateway From fc3f41498cac20fcb7fdb0df026c4b5657faad93 Mon Sep 17 00:00:00 2001 From: Lior Lieberman Date: Thu, 27 Mar 2025 09:42:41 -0700 Subject: [PATCH 158/260] helm-improvements (#590) --- config/charts/inferencepool/README.md | 8 +- .../inferencepool/templates/_validations.tpl | 13 +++ .../templates/epp-deployment.yaml | 58 +++++++++++++ .../inferencepool/templates/epp-service.yaml | 18 ++++ .../templates/inferencepool.yaml | 86 ++----------------- config/charts/inferencepool/values.yaml | 7 +- 6 files changed, 103 insertions(+), 87 deletions(-) create mode 100644 config/charts/inferencepool/templates/_validations.tpl create mode 100644 config/charts/inferencepool/templates/epp-deployment.yaml create mode 100644 config/charts/inferencepool/templates/epp-service.yaml diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md index da9d0a07..12f9959c 100644 --- a/config/charts/inferencepool/README.md +++ b/config/charts/inferencepool/README.md @@ -10,18 +10,18 @@ To install an InferencePool named `vllm-llama2-7b` that selects from endpoints ```txt $ helm install vllm-llama2-7b ./config/charts/inferencepool \ --set inferencePool.name=vllm-llama2-7b \ - --set inferencePool.selector.app=vllm-llama2-7b \ + --set inferencePool.modelServers.matchLabels.app=vllm-llama2-7b \ --set inferencePool.targetPortNumber=8000 ``` -where `inferencePool.targetPortNumber` is the pod that vllm backends served on and `inferencePool.selector` is the selector to match the vllm backends. +where `inferencePool.targetPortNumber` is the pod that vllm backends served on and `inferencePool.modelServers.matchLabels` is the selector to match the vllm backends. To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: ```txt $ helm install vllm-llama2-7b \ --set inferencePool.name=vllm-llama2-7b \ - --set inferencePool.selector.app=vllm-llama2-7b \ + --set inferencePool.modelServers.matchLabels.app=vllm-llama2-7b \ --set inferencePool.targetPortNumber=8000 \ oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0 ``` @@ -42,7 +42,7 @@ The following table list the configurable parameters of the chart. |---------------------------------------------|-------------------------------------------------------------------------------------------------------------------| | `inferencePool.name` | Name for the InferencePool, and inference extension will be named as `${inferencePool.name}-epp`. | | `inferencePool.targetPortNumber` | Target port number for the vllm backends, will be used to scrape metrics by the inference extension. | -| `inferencePool.selector` | Label selector to match vllm backends managed by the inference pool. | +| `inferencePool.modelServers.matchLabels` | Label selector to match vllm backends managed by the inference pool. | | `inferenceExtension.replicas` | Number of replicas for the inference extension service. Defaults to `1`. | | `inferenceExtension.image.name` | Name of the container image used for the inference extension. | | `inferenceExtension.image.hub` | Registry URL where the inference extension image is hosted. | diff --git a/config/charts/inferencepool/templates/_validations.tpl b/config/charts/inferencepool/templates/_validations.tpl new file mode 100644 index 00000000..55ed80c8 --- /dev/null +++ b/config/charts/inferencepool/templates/_validations.tpl @@ -0,0 +1,13 @@ +{{/* +common validations +*/}} +{{- define "gateway-api-inference-extension.validations.inferencepool.common" -}} +{{- if not $.Values.inferencePool.name }} +{{- fail "missing .Values.inferencePool.name" }} +{{- end }} + + +{{- if or (empty $.Values.inferencePool.modelServers) (not $.Values.inferencePool.modelServers.matchLabels) }} +{{- fail ".Values.inferencePool.modelServers.matchLabels is required" }} +{{- end }} +{{- end -}} diff --git a/config/charts/inferencepool/templates/epp-deployment.yaml b/config/charts/inferencepool/templates/epp-deployment.yaml new file mode 100644 index 00000000..ded9cb12 --- /dev/null +++ b/config/charts/inferencepool/templates/epp-deployment.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.inferenceExtension.replicas | default 1 }} + selector: + matchLabels: + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "gateway-api-inference-extension.name" . }} + containers: + - name: epp + image: {{ .Values.inferenceExtension.image.hub }}/{{ .Values.inferenceExtension.image.name }}:{{ .Values.inferenceExtension.image.tag }} + imagePullPolicy: {{ .Values.inferenceExtension.image.pullPolicy | default "Always" }} + args: + - -poolName + - {{ .Values.inferencePool.name }} + - -poolNamespace + - {{ .Release.Namespace }} + - -v + - "3" + - -grpcPort + - "9002" + - -grpcHealthPort + - "9003" + - -metricsPort + - "9090" + env: + - name: USE_STREAMING + value: "true" + ports: + - name: grpc + containerPort: 9002 + - name: grpc-health + containerPort: 9003 + - name: metrics + containerPort: 9090 + livenessProbe: + grpc: + port: 9003 + service: inference-extension + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + grpc: + port: 9003 + service: inference-extension + initialDelaySeconds: 5 + periodSeconds: 10 + diff --git a/config/charts/inferencepool/templates/epp-service.yaml b/config/charts/inferencepool/templates/epp-service.yaml new file mode 100644 index 00000000..ed23db17 --- /dev/null +++ b/config/charts/inferencepool/templates/epp-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "gateway-api-inference-extension.name" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} +spec: + selector: + {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 4 }} + ports: + - name: grpc-ext-proc + protocol: TCP + port: {{ .Values.inferenceExtension.extProcPort | default 9002 }} + - name: http-metrics + protocol: TCP + port: {{ .Values.inferenceExtension.metricsPort | default 9090 }} + type: ClusterIP diff --git a/config/charts/inferencepool/templates/inferencepool.yaml b/config/charts/inferencepool/templates/inferencepool.yaml index fb750f63..2b79f399 100644 --- a/config/charts/inferencepool/templates/inferencepool.yaml +++ b/config/charts/inferencepool/templates/inferencepool.yaml @@ -1,3 +1,4 @@ +{{ include "gateway-api-inference-extension.validations.inferencepool.common" $ }} apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: @@ -8,85 +9,10 @@ metadata: spec: targetPortNumber: {{ .Values.inferencePool.targetPortNumber }} selector: - {{- range $key, $value := .Values.inferencePool.selector }} - {{ $key }}: {{ quote $value }} - {{- end }} + {{- if .Values.inferencePool.modelServers.matchLabels }} + {{- range $key, $value := .Values.inferencePool.modelServers.matchLabels }} + {{ $key }}: {{ quote $value }} + {{- end }} + {{- end }} extensionRef: name: {{ include "gateway-api-inference-extension.name" . }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "gateway-api-inference-extension.name" . }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} -spec: - replicas: {{ .Values.inferenceExtension.replicas | default 1 }} - selector: - matchLabels: - {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 8 }} - spec: - serviceAccountName: {{ include "gateway-api-inference-extension.name" . }} - containers: - - name: epp - image: {{ .Values.inferenceExtension.image.hub }}/{{ .Values.inferenceExtension.image.name }}:{{ .Values.inferenceExtension.image.tag }} - imagePullPolicy: {{ .Values.inferenceExtension.image.pullPolicy | default "Always" }} - args: - - -poolName - - {{ .Values.inferencePool.name }} - - -poolNamespace - - {{ .Release.Namespace }} - - -v - - "3" - - -grpcPort - - "9002" - - -grpcHealthPort - - "9003" - - -metricsPort - - "9090" - env: - - name: USE_STREAMING - value: "true" - ports: - - name: grpc - containerPort: 9002 - - name: grpc-health - containerPort: 9003 - - name: metrics - containerPort: 9090 - livenessProbe: - grpc: - port: 9003 - service: inference-extension - initialDelaySeconds: 5 - periodSeconds: 10 - readinessProbe: - grpc: - port: 9003 - service: inference-extension - initialDelaySeconds: 5 - periodSeconds: 10 ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "gateway-api-inference-extension.name" . }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "gateway-api-inference-extension.labels" . | nindent 4 }} -spec: - selector: - {{- include "gateway-api-inference-extension.selectorLabels" . | nindent 4 }} - ports: - - name: grpc-ext-proc - protocol: TCP - port: {{ .Values.inferenceExtension.extProcPort | default 9002 }} - - name: http-metrics - protocol: TCP - port: {{ .Values.inferenceExtension.metricsPort | default 9090 }} - type: ClusterIP diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml index 7d3e868d..5cece88c 100644 --- a/config/charts/inferencepool/values.yaml +++ b/config/charts/inferencepool/values.yaml @@ -8,7 +8,8 @@ inferenceExtension: extProcPort: 9002 inferencePool: - name: pool-1 + # name: pool-1 # REQUIRED targetPortNumber: 8000 - selector: - app: vllm-llama2-7b + # modelServers: # REQUIRED + # matchLabels: + # app: vllm-llama2-7b From 548063960236c9db4ad57a858d07cd88210644c7 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Thu, 27 Mar 2025 11:06:39 -0700 Subject: [PATCH 159/260] Setting zap to emit logs as JSON in the deployment. (#591) --- config/manifests/inferencepool.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/manifests/inferencepool.yaml b/config/manifests/inferencepool.yaml index ca2e4a88..def892f5 100644 --- a/config/manifests/inferencepool.yaml +++ b/config/manifests/inferencepool.yaml @@ -50,6 +50,8 @@ spec: - "vllm-llama2-7b" - -v - "4" + - --zap-encoder + - "json" - -grpcPort - "9002" - -grpcHealthPort From 3af9eeb53e69fa2d5aa08c683ba7347c44e791f4 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Thu, 27 Mar 2025 14:00:44 -0700 Subject: [PATCH 160/260] Updating llama 2 7b to llama 3.1 8b Instruct and adding new LoRA adapters (#578) --- config/charts/inferencepool/README.md | 14 +++---- config/charts/inferencepool/values.yaml | 2 +- config/manifests/benchmark/benchmark.yaml | 4 +- config/manifests/gateway/patch_policy.yaml | 2 +- config/manifests/inferencemodel.yaml | 14 +++---- config/manifests/inferencepool.yaml | 20 +++++----- config/manifests/vllm/cpu-deployment.yaml | 14 +++---- config/manifests/vllm/gpu-deployment.yaml | 31 +++++++------- hack/test-e2e.sh | 4 +- pkg/epp/datastore/datastore_test.go | 8 ++-- pkg/epp/handlers/response.go | 4 +- pkg/epp/handlers/response_test.go | 6 +-- site-src/guides/adapter-rollout.md | 40 +++++++++---------- site-src/guides/index.md | 10 ++--- site-src/guides/metrics.md | 2 +- test/e2e/epp/README.md | 4 +- test/e2e/epp/e2e_suite_test.go | 6 +-- test/integration/epp/hermetic_test.go | 30 +++++++------- test/testdata/envoy.yaml | 4 +- .../inferencepool-with-model-hermetic.yaml | 12 +++--- tools/dynamic-lora-sidecar/deployment.yaml | 10 ++--- .../sidecar/test_sidecar.py | 14 +++---- 22 files changed, 127 insertions(+), 128 deletions(-) diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md index 12f9959c..30087527 100644 --- a/config/charts/inferencepool/README.md +++ b/config/charts/inferencepool/README.md @@ -5,12 +5,12 @@ A chart to deploy an InferencePool and a corresponding EndpointPicker (epp) depl ## Install -To install an InferencePool named `vllm-llama2-7b` that selects from endpoints with label `app: vllm-llama2-7b` and listening on port `8000`, you can run the following command: +To install an InferencePool named `vllm-llama3-8b-instruct` that selects from endpoints with label `app: vllm-llama3-8b-instruct` and listening on port `8000`, you can run the following command: ```txt -$ helm install vllm-llama2-7b ./config/charts/inferencepool \ - --set inferencePool.name=vllm-llama2-7b \ - --set inferencePool.modelServers.matchLabels.app=vllm-llama2-7b \ +$ helm install vllm-llama3-8b-instruct ./config/charts/inferencepool \ + --set inferencePool.name=vllm-llama3-8b-instruct \ + --set inferencePool.modelServers.matchLabels.app=vllm-llama3-8b-instruct \ --set inferencePool.targetPortNumber=8000 ``` @@ -19,9 +19,9 @@ where `inferencePool.targetPortNumber` is the pod that vllm backends served on a To install via the latest published chart in staging (--version v0 indicates latest dev version), you can run the following command: ```txt -$ helm install vllm-llama2-7b \ - --set inferencePool.name=vllm-llama2-7b \ - --set inferencePool.modelServers.matchLabels.app=vllm-llama2-7b \ +$ helm install vllm-llama3-8b-instruct \ + --set inferencePool.name=vllm-llama3-8b-instruct \ + --set inferencePool.modelServers.matchLabels.app=vllm-llama3-8b-instruct \ --set inferencePool.targetPortNumber=8000 \ oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0 ``` diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml index 5cece88c..7b0c8f96 100644 --- a/config/charts/inferencepool/values.yaml +++ b/config/charts/inferencepool/values.yaml @@ -12,4 +12,4 @@ inferencePool: targetPortNumber: 8000 # modelServers: # REQUIRED # matchLabels: - # app: vllm-llama2-7b + # app: vllm-llama3-8b-instruct diff --git a/config/manifests/benchmark/benchmark.yaml b/config/manifests/benchmark/benchmark.yaml index a47b4617..c784730e 100644 --- a/config/manifests/benchmark/benchmark.yaml +++ b/config/manifests/benchmark/benchmark.yaml @@ -31,9 +31,9 @@ spec: - name: BENCHMARK_TIME_SECONDS value: '60' - name: TOKENIZER - value: 'meta-llama/Llama-2-7b-hf' + value: 'meta-llama/Llama-3.1-8B-Instruct' - name: MODELS - value: 'meta-llama/Llama-2-7b-hf' + value: 'meta-llama/Llama-3.1-8B-Instruct' - name: BACKEND value: vllm - name: PORT diff --git a/config/manifests/gateway/patch_policy.yaml b/config/manifests/gateway/patch_policy.yaml index a40c8e27..923ce22c 100644 --- a/config/manifests/gateway/patch_policy.yaml +++ b/config/manifests/gateway/patch_policy.yaml @@ -99,7 +99,7 @@ spec: - backendRefs: - group: "" kind: Service - name: vllm-llama2-7b-epp + name: vllm-llama3-8b-instruct-epp port: 9002 processingMode: allowModeOverride: true diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index 4c7824ca..bdd4405a 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -3,12 +3,12 @@ kind: InferenceModel metadata: name: inferencemodel-sample spec: - modelName: tweet-summary - criticality: Critical + modelName: food-review + criticality: Standard poolRef: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct targetModels: - - name: tweet-summary-1 + - name: food-review-1 weight: 100 --- @@ -17,10 +17,10 @@ kind: InferenceModel metadata: name: inferencemodel-base-model spec: - modelName: meta-llama/Llama-2-7b-hf + modelName: meta-llama/Llama-3.1-8B-Instruct criticality: Critical poolRef: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct --- apiVersion: inference.networking.x-k8s.io/v1alpha2 @@ -31,4 +31,4 @@ spec: modelName: Qwen/Qwen2.5-1.5B-Instruct criticality: Critical poolRef: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct diff --git a/config/manifests/inferencepool.yaml b/config/manifests/inferencepool.yaml index def892f5..639157c1 100644 --- a/config/manifests/inferencepool.yaml +++ b/config/manifests/inferencepool.yaml @@ -2,22 +2,22 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: labels: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct spec: targetPortNumber: 8000 selector: - app: vllm-llama2-7b + app: vllm-llama3-8b-instruct extensionRef: - name: vllm-llama2-7b-epp + name: vllm-llama3-8b-instruct-epp --- apiVersion: v1 kind: Service metadata: - name: vllm-llama2-7b-epp + name: vllm-llama3-8b-instruct-epp namespace: default spec: selector: - app: vllm-llama2-7b-epp + app: vllm-llama3-8b-instruct-epp ports: - protocol: TCP port: 9002 @@ -27,19 +27,19 @@ spec: apiVersion: apps/v1 kind: Deployment metadata: - name: vllm-llama2-7b-epp + name: vllm-llama3-8b-instruct-epp namespace: default labels: - app: vllm-llama2-7b-epp + app: vllm-llama3-8b-instruct-epp spec: replicas: 1 selector: matchLabels: - app: vllm-llama2-7b-epp + app: vllm-llama3-8b-instruct-epp template: metadata: labels: - app: vllm-llama2-7b-epp + app: vllm-llama3-8b-instruct-epp spec: containers: - name: epp @@ -47,7 +47,7 @@ spec: imagePullPolicy: Always args: - -poolName - - "vllm-llama2-7b" + - "vllm-llama3-8b-instruct" - -v - "4" - --zap-encoder diff --git a/config/manifests/vllm/cpu-deployment.yaml b/config/manifests/vllm/cpu-deployment.yaml index 6ac1014c..6fb40950 100644 --- a/config/manifests/vllm/cpu-deployment.yaml +++ b/config/manifests/vllm/cpu-deployment.yaml @@ -1,16 +1,16 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct spec: replicas: 3 selector: matchLabels: - app: vllm-llama2-7b + app: vllm-llama3-8b-instruct template: metadata: labels: - app: vllm-llama2-7b + app: vllm-llama3-8b-instruct spec: containers: - name: lora @@ -26,8 +26,8 @@ spec: - "--max-loras" - "4" - "--lora-modules" - - '{"name": "tweet-summary-0", "path": "SriSanth2345/Qwen-1.5B-Tweet-Generations", "base_model_name": "Qwen/Qwen2.5-1.5B"}' - - '{"name": "tweet-summary-1", "path": "SriSanth2345/Qwen-1.5B-Tweet-Generations", "base_model_name": "Qwen/Qwen2.5-1.5B"}' + - '{"name": "food-review-0", "path": "SriSanth2345/Qwen-1.5B-Tweet-Generations", "base_model_name": "Qwen/Qwen2.5-1.5B"}' + - '{"name": "food-review-1", "path": "SriSanth2345/Qwen-1.5B-Tweet-Generations", "base_model_name": "Qwen/Qwen2.5-1.5B"}' env: - name: PORT value: "8000" @@ -108,10 +108,10 @@ metadata: data: configmap.yaml: | vLLMLoRAConfig: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct port: 8000 ensureExist: models: - base-model: Qwen/Qwen2.5-1.5B - id: tweet-summary-1 + id: food-review-1 source: SriSanth2345/Qwen-1.5B-Tweet-Generations \ No newline at end of file diff --git a/config/manifests/vllm/gpu-deployment.yaml b/config/manifests/vllm/gpu-deployment.yaml index e9507601..c405b33c 100644 --- a/config/manifests/vllm/gpu-deployment.yaml +++ b/config/manifests/vllm/gpu-deployment.yaml @@ -1,37 +1,34 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: vllm-llama2-7b + name: vllm-llama3-8b-instruct spec: replicas: 3 selector: matchLabels: - app: vllm-llama2-7b + app: vllm-llama3-8b-instruct template: metadata: labels: - app: vllm-llama2-7b + app: vllm-llama3-8b-instruct spec: containers: - - name: lora + - name: vllm image: "vllm/vllm-openai:latest" imagePullPolicy: Always command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] args: - "--model" - - "meta-llama/Llama-2-7b-hf" + - "meta-llama/Llama-3.1-8B-Instruct" - "--tensor-parallel-size" - "1" - "--port" - "8000" - "--enable-lora" - "--max-loras" - - "4" + - "2" - "--max-cpu-loras" - "12" - - "--lora-modules" - - '{"name": "tweet-summary-0", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' - - '{"name": "tweet-summary-1", "path": "vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm", "base_model_name": "llama-2"}' env: # Enabling LoRA support temporarily disables automatic v1, we want to force it on # until 0.8.3 vLLM is released. @@ -238,20 +235,22 @@ spec: emptyDir: {} - name: config-volume configMap: - name: vllm-llama2-7b-adapters + name: vllm-llama3.1-8b-adapters --- apiVersion: v1 kind: ConfigMap metadata: - name: vllm-llama2-7b-adapters + name: vllm-llama3.1-8b-adapters data: configmap.yaml: | vLLMLoRAConfig: - name: vllm-llama2-7b + name: vllm-llama3.1-8b-instruct port: 8000 ensureExist: models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-1 - source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - + - base-model: meta-llama/Llama-3.1-8B-Instruct + id: food-review + source: Kawon/llama3.1-food-finetune_v14_r8 + - base-model: meta-llama/Llama-3.1-8B-Instruct + id: cad-fabricator + source: redcathode/fabricator diff --git a/hack/test-e2e.sh b/hack/test-e2e.sh index 716e626a..0d6bdfc0 100755 --- a/hack/test-e2e.sh +++ b/hack/test-e2e.sh @@ -124,14 +124,14 @@ if [[ "$CURL_POD" == "true" ]]; then while [ $SECONDS -lt $end ]; do kubectl exec po/curl -- curl -i "$IP:$PORT/v1/completions" \ -H 'Content-Type: application/json' \ - -d '{"model": "tweet-summary","prompt": "Write as if you were a critic: San Francisco","max_tokens": 100,"temperature": 0}' + -d '{"model": "food-review","prompt": "Write as if you were a critic: San Francisco","max_tokens": 100,"temperature": 0}' sleep 5 done else while [ $SECONDS -lt $end ]; do curl -i "$IP:$PORT/v1/completions" \ -H 'Content-Type: application/json' \ - -d '{"model": "tweet-summary","prompt": "Write as if you were a critic: San Francisco","max_tokens": 100,"temperature": 0}' + -d '{"model": "food-review","prompt": "Write as if you were a critic: San Francisco","max_tokens": 100,"temperature": 0}' sleep 5 done fi diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index 1a88e5dc..22bb0365 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -97,7 +97,7 @@ func TestPool(t *testing.T) { func TestModel(t *testing.T) { chatModel := "chat" - tsModel := "tweet-summary" + tsModel := "food-review" model1ts := testutil.MakeInferenceModel("model1"). CreationTimestamp(metav1.Unix(1000, 0)). ModelName(tsModel).ObjRef() @@ -126,7 +126,7 @@ func TestModel(t *testing.T) { wantModels []*v1alpha2.InferenceModel }{ { - name: "Add model1 with tweet-summary as modelName", + name: "Add model1 with food-review as modelName", op: func(ds Datastore) bool { return ds.ModelSetIfOlder(model1ts) }, @@ -161,7 +161,7 @@ func TestModel(t *testing.T) { wantModels: []*v1alpha2.InferenceModel{model2ts}, }, { - name: "Set model1 with the tweet-summary modelName, both models should exist", + name: "Set model1 with the food-review modelName, both models should exist", existingModels: []*v1alpha2.InferenceModel{model2chat}, op: func(ds Datastore) bool { return ds.ModelSetIfOlder(model1ts) @@ -170,7 +170,7 @@ func TestModel(t *testing.T) { wantModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, }, { - name: "Set model1 with the tweet-summary modelName, both models should exist", + name: "Set model1 with the food-review modelName, both models should exist", existingModels: []*v1alpha2.InferenceModel{model2chat, model1ts}, op: func(ds Datastore) bool { return ds.ModelSetIfOlder(model1ts) diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index cf64f4a4..991b7d16 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -127,7 +127,7 @@ func (s *Server) HandleResponseHeaders( "id": "cmpl-573498d260f2423f9e42817bbba3743a", "object": "text_completion", "created": 1732563765, - "model": "meta-llama/Llama-2-7b-hf", + "model": "meta-llama/Llama-3.1-8B-Instruct", "choices": [ { "index": 0, @@ -217,7 +217,7 @@ func (s *Server) HandleStreaming( } // Example message if "stream_options": {"include_usage": "true"} is included in the request: -// data: {"id":"...","object":"text_completion","created":1739400043,"model":"tweet-summary-0","choices":[], +// data: {"id":"...","object":"text_completion","created":1739400043,"model":"food-review-0","choices":[], // "usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} // // data: [DONE] diff --git a/pkg/epp/handlers/response_test.go b/pkg/epp/handlers/response_test.go index edfa3edb..074b45c9 100644 --- a/pkg/epp/handlers/response_test.go +++ b/pkg/epp/handlers/response_test.go @@ -31,7 +31,7 @@ const ( "id": "cmpl-573498d260f2423f9e42817bbba3743a", "object": "text_completion", "created": 1732563765, - "model": "meta-llama/Llama-2-7b-hf", + "model": "meta-llama/Llama-3.1-8B-Instruct", "choices": [ { "index": 0, @@ -50,10 +50,10 @@ const ( } ` - streamingBodyWithoutUsage = `data: {"id":"cmpl-41764c93-f9d2-4f31-be08-3ba04fa25394","object":"text_completion","created":1740002445,"model":"tweet-summary-0","choices":[],"usage":null} + streamingBodyWithoutUsage = `data: {"id":"cmpl-41764c93-f9d2-4f31-be08-3ba04fa25394","object":"text_completion","created":1740002445,"model":"food-review-0","choices":[],"usage":null} ` - streamingBodyWithUsage = `data: {"id":"cmpl-41764c93-f9d2-4f31-be08-3ba04fa25394","object":"text_completion","created":1740002445,"model":"tweet-summary-0","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} + streamingBodyWithUsage = `data: {"id":"cmpl-41764c93-f9d2-4f31-be08-3ba04fa25394","object":"text_completion","created":1740002445,"model":"food-review-0","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} data: [DONE] ` ) diff --git a/site-src/guides/adapter-rollout.md b/site-src/guides/adapter-rollout.md index 9ce8c3a4..18d60ece 100644 --- a/site-src/guides/adapter-rollout.md +++ b/site-src/guides/adapter-rollout.md @@ -18,7 +18,7 @@ Modify the LoRA syncer ConfigMap to initiate loading of the new adapter version. ```bash - kubectl edit configmap vllm-llama2-7b-adapters + kubectl edit configmap vllm-llama3-8b-instruct-adapters ``` Change the ConfigMap to match the following (note the new entry under models): @@ -27,19 +27,19 @@ Change the ConfigMap to match the following (note the new entry under models): apiVersion: v1 kind: ConfigMap metadata: - name: vllm-llama2-7b-adapters + name: vllm-llama3-8b-instruct-adapters data: configmap.yaml: | vLLMLoRAConfig: - name: vllm-llama2-7b-adapters + name: vllm-llama3-8b-instruct-adapters port: 8000 ensureExist: models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-1 + - base-model: meta-llama/Llama-3.1-8B-Instruct + id: food-review-1 source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-2 + - base-model: meta-llama/Llama-3.1-8B-Instruct + id: food-review-2 source: mahimairaja/tweet-summarization-llama-2-finetuned ``` @@ -48,11 +48,11 @@ The new adapter version is applied to the model servers live, without requiring ### Direct traffic to the new adapter version -Modify the InferenceModel to configure a canary rollout with traffic splitting. In this example, 10% of traffic for tweet-summary model will be sent to the new ***tweet-summary-2*** adapter. +Modify the InferenceModel to configure a canary rollout with traffic splitting. In this example, 10% of traffic for food-review model will be sent to the new ***food-review-2*** adapter. ```bash - kubectl edit inferencemodel tweet-summary + kubectl edit inferencemodel food-review ``` Change the targetModels list in InferenceModel to match the following: @@ -64,14 +64,14 @@ kind: InferenceModel metadata: name: inferencemodel-sample spec: - modelName: tweet-summary + modelName: food-review criticality: Critical poolRef: - name: vllm-llama2-7b-pool + name: vllm-llama3-8b-instruct-pool targetModels: - - name: tweet-summary-1 + - name: food-review-1 weight: 90 - - name: tweet-summary-2 + - name: food-review-2 weight: 10 ``` @@ -86,7 +86,7 @@ IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].va 2. Send a few requests as follows: ```bash curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ -"model": "tweet-summary", +"model": "food-review", "prompt": "Write as if you were a critic: San Francisco", "max_tokens": 100, "temperature": 0 @@ -100,9 +100,9 @@ Modify the InferenceModel to direct 100% of the traffic to the latest version of ```yaml model: - name: tweet-summary + name: food-review targetModels: - targetModelName: tweet-summary-2 + targetModelName: food-review-2 weight: 100 ``` @@ -120,13 +120,13 @@ Unload the older versions from the servers by updating the LoRA syncer ConfigMap port: 8000 ensureExist: models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-2 + - base-model: meta-llama/Llama-3.1-8B-Instruct + id: food-review-2 source: mahimairaja/tweet-summarization-llama-2-finetuned ensureNotExist: models: - - base-model: meta-llama/Llama-2-7b-hf - id: tweet-summary-1 + - base-model: meta-llama/Llama-3.1-8B-Instruct + id: food-review-1 source: vineetsharma/qlora-adapter-Llama-2-7b-hf-TweetSumm ``` diff --git a/site-src/guides/index.md b/site-src/guides/index.md index bcea5f9b..99b78129 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -17,7 +17,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv Two options are supported for running the model server: 1. GPU-based model server. - Requirements: a Hugging Face access token that grants access to the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). + Requirements: a Hugging Face access token that grants access to the model [meta-llama/Llama-3.1-8B-Instruct](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct). 1. CPU-based model server (not using GPUs). The sample uses the model [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct). @@ -27,11 +27,11 @@ This quickstart guide is intended for engineers familiar with k8s and model serv === "GPU-Based Model Server" For this setup, you will need 3 GPUs to run the sample model server. Adjust the number of replicas in `./config/manifests/vllm/gpu-deployment.yaml` as needed. - Create a Hugging Face secret to download the model [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf). Ensure that the token grants access to this model. + Create a Hugging Face secret to download the model [meta-llama/Llama-3.1-8B-Instruct](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct). Ensure that the token grants access to this model. Deploy a sample vLLM deployment with the proper protocol to work with the LLM Instance Gateway. ```bash - kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to Llama2 + kubectl create secret generic hf-token --from-literal=token=$HF_TOKEN # Your Hugging Face Token with access to the set of Llama models kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/vllm/gpu-deployment.yaml ``` @@ -59,7 +59,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv ### Deploy InferenceModel - Deploy the sample InferenceModel which is configured to load balance traffic between the `tweet-summary-0` and `tweet-summary-1` + Deploy the sample InferenceModel which is configured to load balance traffic between the `food-review-0` and `food-review-1` [LoRA adapters](https://docs.vllm.ai/en/latest/features/lora.html) of the sample model server. ```bash kubectl apply -f https://github.com/kubernetes-sigs/gateway-api-inference-extension/raw/main/config/manifests/inferencemodel.yaml @@ -116,7 +116,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv PORT=8081 curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ - "model": "tweet-summary", + "model": "food-review", "prompt": "Write as if you were a critic: San Francisco", "max_tokens": 100, "temperature": 0 diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index a904145d..12ff892e 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -29,7 +29,7 @@ If you want to include usage metrics for vLLM model server streaming request, se ``` curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ -"model": "tweet-summary", +"model": "food-review", "prompt": "whats your fav movie?", "max_tokens": 10, "temperature": 0, diff --git a/test/e2e/epp/README.md b/test/e2e/epp/README.md index 584d8914..247e8b12 100644 --- a/test/e2e/epp/README.md +++ b/test/e2e/epp/README.md @@ -10,7 +10,7 @@ The end-to-end tests are designed to validate end-to-end Gateway API Inference E - [Go](https://golang.org/doc/install) installed on your machine. - [Make](https://www.gnu.org/software/make/manual/make.html) installed to run the end-to-end test target. -- A Hugging Face Hub token with access to the [meta-llama/Llama-2-7b-hf](https://huggingface.co/meta-llama/Llama-2-7b-hf) model. +- A Hugging Face Hub token with access to the [meta-llama/Llama-3.1-8B-Instruct](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct) model. ## Running the End-to-End Tests @@ -34,5 +34,5 @@ Follow these steps to run the end-to-end tests: make test-e2e ``` - The test suite prints details for each step. Note that the `vllm-llama2-7b-pool` model server deployment + The test suite prints details for each step. Note that the `vllm-llama3-8b-instruct-pool` model server deployment may take several minutes to report an `Available=True` status due to the time required for bootstraping. diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go index 92521bf7..f9dea1cc 100644 --- a/test/e2e/epp/e2e_suite_test.go +++ b/test/e2e/epp/e2e_suite_test.go @@ -57,15 +57,15 @@ const ( // TODO [danehans]: Must be "default" until https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/227 is fixed nsName = "default" // modelServerName is the name of the model server test resources. - modelServerName = "vllm-llama2-7b" + modelServerName = "vllm-llama3-8b-instruct" // modelName is the test model name. - modelName = "tweet-summary" + modelName = "food-review" // envoyName is the name of the envoy proxy test resources. envoyName = "envoy" // envoyPort is the listener port number of the test envoy proxy. envoyPort = "8081" // inferExtName is the name of the inference extension test resources. - inferExtName = "vllm-llama2-7b-epp" + inferExtName = "vllm-llama3-8b-instruct-epp" // clientManifest is the manifest for the client test resources. clientManifest = "../../testdata/client.yaml" // modelServerSecretManifest is the manifest for the model server secret resource. diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index b12925ed..8e02aca4 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -1198,42 +1198,42 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"NEVER","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"NEVER","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false}, }, }, { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GONNA","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"GONNA","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false}, }, }, { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GIVE","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"GIVE","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false}, }, }, { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"YOU","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"YOU","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false}, }, }, { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"UP","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"UP","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false}, }, }, { Request: &extProcPb.ProcessingRequest_ResponseBody{ ResponseBody: &extProcPb.HttpBody{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} data: [DONE]`, ), EndOfStream: false}, @@ -1300,7 +1300,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"NEVER","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"NEVER","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false, }, }, @@ -1316,7 +1316,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GONNA","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"GONNA","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false, }, }, @@ -1332,7 +1332,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"GIVE","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"GIVE","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false, }, }, @@ -1348,7 +1348,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"YOU","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"YOU","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false, }, }, @@ -1364,7 +1364,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[{"index":0,"text":"UP","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[{"index":0,"text":"UP","logprobs":null,"finish_reason":null,"stop_reason":null}],"usage":null}`), EndOfStream: false, }, }, @@ -1380,7 +1380,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { BodyMutation: &extProcPb.BodyMutation{ Mutation: &extProcPb.BodyMutation_StreamedResponse{ StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"tweet-summary-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} + Body: []byte(`data: {"id":"cmpl-0fee233f-7d56-404a-acd3-4dad775d03d9","object":"text_completion","created":1741379018,"model":"food-review-1","choices":[],"usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} data: [DONE]`, ), EndOfStream: false, @@ -1507,7 +1507,7 @@ func setUpHermeticServer(t *testing.T, podAndMetrics map[backendmetrics.Pod]*bac // TODO: this should be consistent with the inference pool podLabels := map[string]string{ - "app": "vllm-llama2-7b-pool", + "app": "vllm-llama3-8b-instruct-pool", } for pod := range podAndMetrics { @@ -1602,7 +1602,7 @@ func BeforeSuite() func() { // Init runtime. ctrl.SetLogger(logger) - mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama2-7b-pool")) + mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama3-8b-instruct-pool")) if err != nil { logutil.Fatal(logger, err, "Failed to create controller manager") } @@ -1615,7 +1615,7 @@ func BeforeSuite() func() { serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) // Adjust from defaults - serverRunner.PoolName = "vllm-llama2-7b-pool" + serverRunner.PoolName = "vllm-llama3-8b-instruct-pool" serverRunner.Datastore = datastore.NewDatastore(context.Background(), pmf) serverRunner.SecureServing = false diff --git a/test/testdata/envoy.yaml b/test/testdata/envoy.yaml index 2598428c..fc32b5aa 100644 --- a/test/testdata/envoy.yaml +++ b/test/testdata/envoy.yaml @@ -100,7 +100,7 @@ data: grpc_service: envoy_grpc: cluster_name: ext_proc - authority: vllm-llama2-7b-epp.default:9002 + authority: vllm-llama3-8b-instruct-epp.default:9002 timeout: 10s processing_mode: request_header_mode: SEND @@ -194,7 +194,7 @@ data: - endpoint: address: socket_address: - address: vllm-llama2-7b-epp.default + address: vllm-llama3-8b-instruct-epp.default port_value: 9002 health_status: HEALTHY load_balancing_weight: 1 diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml index 36b6e539..d006e047 100644 --- a/test/testdata/inferencepool-with-model-hermetic.yaml +++ b/test/testdata/inferencepool-with-model-hermetic.yaml @@ -1,12 +1,12 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: - name: vllm-llama2-7b-pool + name: vllm-llama3-8b-instruct-pool namespace: default spec: targetPortNumber: 8000 selector: - app: vllm-llama2-7b-pool + app: vllm-llama3-8b-instruct-pool extensionRef: name: epp --- @@ -19,7 +19,7 @@ spec: modelName: sql-lora criticality: Critical poolRef: - name: vllm-llama2-7b-pool + name: vllm-llama3-8b-instruct-pool targetModels: - name: sql-lora-1fdg2 weight: 100 @@ -32,7 +32,7 @@ metadata: spec: modelName: sql-lora-sheddable poolRef: - name: vllm-llama2-7b-pool + name: vllm-llama3-8b-instruct-pool targetModels: - name: sql-lora-1fdg3 weight: 100 @@ -46,7 +46,7 @@ spec: modelName: my-model criticality: Critical poolRef: - name: vllm-llama2-7b-pool + name: vllm-llama3-8b-instruct-pool targetModels: - name: my-model-12345 weight: 100 @@ -60,4 +60,4 @@ spec: modelName: direct-model criticality: Critical poolRef: - name: vllm-llama2-7b-pool \ No newline at end of file + name: vllm-llama3-8b-instruct-pool \ No newline at end of file diff --git a/tools/dynamic-lora-sidecar/deployment.yaml b/tools/dynamic-lora-sidecar/deployment.yaml index 9e9fc130..0a20ec66 100644 --- a/tools/dynamic-lora-sidecar/deployment.yaml +++ b/tools/dynamic-lora-sidecar/deployment.yaml @@ -32,7 +32,7 @@ spec: nvidia.com/gpu : 1 command: ["/bin/sh", "-c"] args: - - vllm serve meta-llama/Llama-2-7b-hf + - vllm serve meta-llama/Llama-3.1-8B-Instruct - --host=0.0.0.0 - --port=8000 - --tensor-parallel-size=1 @@ -111,17 +111,17 @@ data: port: modelServerPort ensureExist: models: - - base-model: meta-llama/Llama-2-7b-hf + - base-model: meta-llama/Llama-3.1-8B-Instruct id: sql-lora-v1 source: yard1/llama-2-7b-sql-lora-test - - base-model: meta-llama/Llama-2-7b-hf + - base-model: meta-llama/Llama-3.1-8B-Instruct id: sql-lora-v3 source: yard1/llama-2-7b-sql-lora-test - - base-model: meta-llama/Llama-2-7b-hf + - base-model: meta-llama/Llama-3.1-8B-Instruct id: sql-lora-v4 source: yard1/llama-2-7b-sql-lora-test ensureNotExist: models: - - base-model: meta-llama/Llama-2-7b-hf + - base-model: meta-llama/Llama-3.1-8B-Instruct id: sql-lora-v2 source: yard1/llama-2-7b-sql-lora-test \ No newline at end of file diff --git a/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py b/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py index 738c7449..6f7e447f 100644 --- a/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py +++ b/tools/dynamic-lora-sidecar/sidecar/test_sidecar.py @@ -12,17 +12,17 @@ "ensureExist": { "models": [ { - "base-model": "meta-llama/Llama-2-7b-hf", + "base-model": "meta-llama/Llama-3.1-8B-Instruct", "id": "sql-lora-v1", "source": "yard1/llama-2-7b-sql-lora-test", }, { - "base-model": "meta-llama/Llama-2-7b-hf", + "base-model": "meta-llama/Llama-3.1-8B-Instruct", "id": "sql-lora-v3", "source": "yard1/llama-2-7b-sql-lora-test", }, { - "base-model": "meta-llama/Llama-2-7b-hf", + "base-model": "meta-llama/Llama-3.1-8B-Instruct", "id": "already_exists", "source": "yard1/llama-2-7b-sql-lora-test", }, @@ -31,17 +31,17 @@ "ensureNotExist": { "models": [ { - "base-model": "meta-llama/Llama-2-7b-hf", + "base-model": "meta-llama/Llama-3.1-8B-Instruct", "id": "sql-lora-v2", "source": "yard1/llama-2-7b-sql-lora-test", }, { - "base-model": "meta-llama/Llama-2-7b-hf", + "base-model": "meta-llama/Llama-3.1-8B-Instruct", "id": "sql-lora-v3", "source": "yard1/llama-2-7b-sql-lora-test", }, { - "base-model": "meta-llama/Llama-2-7b-hf", + "base-model": "meta-llama/Llama-3.1-8B-Instruct", "id": "to_remove", "source": "yard1/llama-2-7b-sql-lora-test", }, @@ -67,7 +67,7 @@ "object": "model", "created": 1729693000, "owned_by": "vllm", - "root": "meta-llama/Llama-2-7b-hf", + "root": "meta-llama/Llama-3.1-8B-Instruct", "parent": None, "max_model_len": 4096, }, From 41822654ac62adf9af6bc69f8a57daf39acad2ff Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Thu, 27 Mar 2025 15:28:44 -0700 Subject: [PATCH 161/260] Renaming resources to better mirror what naming is expected to look like (#592) --- config/manifests/inferencemodel.yaml | 6 +++--- config/samples/gateway_v1alpha1_inferencemodel.yaml | 4 ++-- config/samples/gateway_v1alpha1_inferencepool.yaml | 2 +- test/testdata/inferencepool-with-model-hermetic.yaml | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/config/manifests/inferencemodel.yaml b/config/manifests/inferencemodel.yaml index bdd4405a..eaf05c75 100644 --- a/config/manifests/inferencemodel.yaml +++ b/config/manifests/inferencemodel.yaml @@ -1,7 +1,7 @@ apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-sample + name: tweet-summarizer spec: modelName: food-review criticality: Standard @@ -15,7 +15,7 @@ spec: apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-base-model + name: base-model spec: modelName: meta-llama/Llama-3.1-8B-Instruct criticality: Critical @@ -26,7 +26,7 @@ spec: apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-base-model-cpu + name: base-model-cpu spec: modelName: Qwen/Qwen2.5-1.5B-Instruct criticality: Critical diff --git a/config/samples/gateway_v1alpha1_inferencemodel.yaml b/config/samples/gateway_v1alpha1_inferencemodel.yaml index f1f46a2f..34ea0680 100644 --- a/config/samples/gateway_v1alpha1_inferencemodel.yaml +++ b/config/samples/gateway_v1alpha1_inferencemodel.yaml @@ -4,12 +4,12 @@ metadata: labels: app.kubernetes.io/name: api app.kubernetes.io/managed-by: kustomize - name: inferencemodel-sample + name: sample-sql-assist spec: criticality: Critical modelName: sql-code-assist poolRef: - name: inferencepool-sample + name: vllm-llama-31-8b-sample-pool targetModels: - name: npc-bot-v1 weight: 50 diff --git a/config/samples/gateway_v1alpha1_inferencepool.yaml b/config/samples/gateway_v1alpha1_inferencepool.yaml index 42ac6296..4993d786 100644 --- a/config/samples/gateway_v1alpha1_inferencepool.yaml +++ b/config/samples/gateway_v1alpha1_inferencepool.yaml @@ -4,7 +4,7 @@ metadata: labels: app.kubernetes.io/name: api app.kubernetes.io/managed-by: kustomize - name: inferencepool-sample + name: vllm-llama-31-8b-sample-pool spec: selector: app: npc-bot diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml index d006e047..0c1e518f 100644 --- a/test/testdata/inferencepool-with-model-hermetic.yaml +++ b/test/testdata/inferencepool-with-model-hermetic.yaml @@ -13,7 +13,7 @@ spec: apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-sample + name: sample namespace: default spec: modelName: sql-lora @@ -27,7 +27,7 @@ spec: apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-sheddable + name: sheddable namespace: default spec: modelName: sql-lora-sheddable @@ -40,7 +40,7 @@ spec: apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-generic + name: generic namespace: default spec: modelName: my-model @@ -54,7 +54,7 @@ spec: apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferenceModel metadata: - name: inferencemodel-direct-model-name + name: direct-model-name namespace: default spec: modelName: direct-model From 16ded6689ff224aeaf739a738202b8e98ab112b9 Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Thu, 27 Mar 2025 19:12:46 -0700 Subject: [PATCH 162/260] update algorithm parameters from env variables (#580) * update algorithm parameters form env variables * move env parsers to a new pkg in utils * add unit test for env parser * remove logging env variables during scheduling * add test for env parser --- pkg/epp/scheduling/filter.go | 4 +- pkg/epp/scheduling/filter_test.go | 26 ++++-- pkg/epp/scheduling/scheduler.go | 52 +++++++---- pkg/epp/util/env/env.go | 51 +++++++++++ pkg/epp/util/env/env_test.go | 144 ++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 pkg/epp/util/env/env.go create mode 100644 pkg/epp/util/env/env_test.go diff --git a/pkg/epp/scheduling/filter.go b/pkg/epp/scheduling/filter.go index cee683c5..f4848089 100644 --- a/pkg/epp/scheduling/filter.go +++ b/pkg/epp/scheduling/filter.go @@ -141,7 +141,7 @@ func leastQueuingFilterFunc(logger logr.Logger, req *LLMRequest, pods []backendm } func lowQueueingPodPredicate(_ *LLMRequest, pod backendmetrics.PodMetrics) bool { - return pod.GetMetrics().WaitingQueueSize < queueingThresholdLoRA + return pod.GetMetrics().WaitingQueueSize < config.QueueingThresholdLoRA } // leastKVCacheFilterFunc finds the max and min KV cache of all pods, divides the whole range @@ -223,7 +223,7 @@ func loRASoftAffinityFilter(logger logr.Logger, req *LLMRequest, pods []backendm // If both groups have pods, use probability to select which group to return if len(filtered_affinity) > 0 && len(filtered_available) > 0 { - if randGen.Float64() < loraAffinityThreshold { + if randGen.Float64() < config.LoraAffinityThreshold { return filtered_affinity, nil } return filtered_available, nil diff --git a/pkg/epp/scheduling/filter_test.go b/pkg/epp/scheduling/filter_test.go index 62ffe7f2..127e6c21 100644 --- a/pkg/epp/scheduling/filter_test.go +++ b/pkg/epp/scheduling/filter_test.go @@ -442,6 +442,18 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { tolerancePercent = 5.0 // Allow 5% tolerance from expected distribution ) + // Save original config value to restore later + originalThreshold := config.LoraAffinityThreshold + + // Set a specific test value for this test + testThreshold := 0.75 // 75% + config.LoraAffinityThreshold = testThreshold + + // Ensure we restore the original threshold when test completes + defer func() { + config.LoraAffinityThreshold = originalThreshold + }() + // Create a test request and pods req := &LLMRequest{ Model: testAffinityModel, @@ -472,9 +484,10 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { affinityCount := 0 availableCount := 0 - // Use the actual loraAffinityThreshold as defined in the original code - // This test should work with whatever value is set there - expectedAffinityPercent := loraAffinityThreshold * 100 + // Use the test threshold value + expectedAffinityPercent := config.LoraAffinityThreshold * 100 + expectedAvailabilityPercent := 100 - expectedAffinityPercent + for i := 0; i < numIterations; i++ { result, err := loRASoftAffinityFilter(logger, req, toInterface(pods)) if err != nil { @@ -502,11 +515,12 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { affinityLowerBound := expectedAffinityPercent - tolerancePercent affinityUpperBound := expectedAffinityPercent + tolerancePercent - availableLowerBound := actualAvailablePercent - tolerancePercent - availableUpperBound := actualAvailablePercent + tolerancePercent + availableLowerBound := expectedAvailabilityPercent - tolerancePercent + availableUpperBound := expectedAvailabilityPercent + tolerancePercent t.Logf("Distribution results over %d iterations:", numIterations) - t.Logf("Expected affinity percent: %.2f%% (threshold: %.2f)", expectedAffinityPercent, loraAffinityThreshold) + t.Logf("Expected affinity percent: %.2f%% (threshold: %.2f)", expectedAffinityPercent, config.LoraAffinityThreshold) + t.Logf("Expected availability percent: %.2f%% (threshold: %.2f)", expectedAvailabilityPercent, config.LoraAffinityThreshold) t.Logf("Actual affinity percent: %.2f%% (%d out of %d)", actualAffinityPercent, affinityCount, numIterations) t.Logf("Actual available percent: %.2f%% (%d out of %d)", actualAvailablePercent, availableCount, numIterations) diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 63d829a1..e874724d 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -26,24 +26,46 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +// Config holds all the configuration values for the scheduler +type Config struct { + KVCacheThreshold float64 + QueueThresholdCritical int + QueueingThresholdLoRA int + LoraAffinityThreshold float64 +} + const ( - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16) Make this configurable. - kvCacheThreshold = 0.8 - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16) Make this configurable. - queueThresholdCritical = 5 - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16) Make this configurable. - // the threshold for queued requests to be considered low below which we can prioritize LoRA affinity. - // The value of 128 is arrived heuristicically based on experiments. - queueingThresholdLoRA = 128 - // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16) Make this configurable. - // loraAffinityThreshold indicates the probability with which we prefer a pod with LoRA affinity over a pod without but having room to fit more LoRA adapters. - loraAffinityThreshold = 0.999 + // Default values to use if environment variables are not set + defaultKVCacheThreshold = 0.8 + defaultQueueThresholdCritical = 5 + defaultQueueingThresholdLoRA = 128 + defaultLoraAffinityThreshold = 0.999 ) +// LoadConfig loads configuration from environment variables +func LoadConfig() Config { + // Use a default logger for initial configuration loading + baseLogger := log.Log.WithName("scheduling-config") + + config := Config{ + KVCacheThreshold: envutil.GetEnvFloat("KV_CACHE_THRESHOLD", defaultKVCacheThreshold, baseLogger), + QueueThresholdCritical: envutil.GetEnvInt("QUEUE_THRESHOLD_CRITICAL", defaultQueueThresholdCritical, baseLogger), + QueueingThresholdLoRA: envutil.GetEnvInt("QUEUING_THRESHOLD_LORA", defaultQueueingThresholdLoRA, baseLogger), + LoraAffinityThreshold: envutil.GetEnvFloat("LORA_AFFINITY_THRESHOLD", defaultLoraAffinityThreshold, baseLogger), + } + + baseLogger.V(logutil.DEFAULT).Info("Scheduler configuration loaded", "config", config) + + return config +} + +var config = LoadConfig() + var ( defaultFilter = &filter{ name: "critical request", @@ -92,7 +114,7 @@ var ( // cache below a certain threshold, we consider this model server has capacity to handle // a sheddable request without impacting critical requests. name: "has capacity for sheddable requests", - filter: toFilterFunc(noQueueAndLessThanKVCacheThresholdPredicate(queueThresholdCritical, kvCacheThreshold)), + filter: toFilterFunc(noQueueAndLessThanKVCacheThresholdPredicate(config.QueueThresholdCritical, config.KVCacheThreshold)), nextOnSuccess: queueLoRAAndKVCacheFilter, // If all pods are queuing or running above the KVCache threshold, we drop the sheddable // request to make room for critical requests. @@ -123,13 +145,13 @@ type Scheduler struct { // Schedule finds the target pod based on metrics and the requested lora adapter. func (s *Scheduler) Schedule(ctx context.Context, req *LLMRequest) (targetPod backendmetrics.PodMetrics, err error) { logger := log.FromContext(ctx).WithValues("request", req) - podMetrics := s.datastore.PodGetAll() + podMetrics := s.datastore.PodGetAll() logger.V(logutil.DEBUG).Info(fmt.Sprintf("Scheduling a request. Metrics: %+v", podMetrics)) + pods, err := s.filter.Filter(logger, req, podMetrics) if err != nil || len(pods) == 0 { - return nil, fmt.Errorf( - "failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) + return nil, fmt.Errorf("failed to apply filter, resulted %v pods, this should never happen: %w", len(pods), err) } logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(pods), pods)) i := rand.Intn(len(pods)) diff --git a/pkg/epp/util/env/env.go b/pkg/epp/util/env/env.go new file mode 100644 index 00000000..11e3bde1 --- /dev/null +++ b/pkg/epp/util/env/env.go @@ -0,0 +1,51 @@ +package env + +import ( + "os" + "strconv" + + "github.com/go-logr/logr" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// getEnvFloat gets a float64 from an environment variable with a default value +func GetEnvFloat(key string, defaultVal float64, logger logr.Logger) float64 { + val, exists := os.LookupEnv(key) + if !exists { + logger.V(logutil.VERBOSE).Info("Environment variable not set, using default value", + "key", key, "defaultValue", defaultVal) + return defaultVal + } + + floatVal, err := strconv.ParseFloat(val, 64) + if err != nil { + logger.V(logutil.VERBOSE).Info("Failed to parse environment variable as float, using default value", + "key", key, "value", val, "error", err, "defaultValue", defaultVal) + return defaultVal + } + + logger.V(logutil.VERBOSE).Info("Successfully loaded environment variable", + "key", key, "value", floatVal) + return floatVal +} + +// getEnvInt gets an int from an environment variable with a default value +func GetEnvInt(key string, defaultVal int, logger logr.Logger) int { + val, exists := os.LookupEnv(key) + if !exists { + logger.V(logutil.VERBOSE).Info("Environment variable not set, using default value", + "key", key, "defaultValue", defaultVal) + return defaultVal + } + + intVal, err := strconv.Atoi(val) + if err != nil { + logger.V(logutil.VERBOSE).Info("Failed to parse environment variable as int, using default value", + "key", key, "value", val, "error", err, "defaultValue", defaultVal) + return defaultVal + } + + logger.V(logutil.VERBOSE).Info("Successfully loaded environment variable", + "key", key, "value", intVal) + return intVal +} diff --git a/pkg/epp/util/env/env_test.go b/pkg/epp/util/env/env_test.go new file mode 100644 index 00000000..02513e28 --- /dev/null +++ b/pkg/epp/util/env/env_test.go @@ -0,0 +1,144 @@ +package env + +import ( + "os" + "testing" + + "github.com/go-logr/logr/testr" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func TestGetEnvFloat(t *testing.T) { + logger := testr.New(t) + + tests := []struct { + name string + key string + value string + defaultVal float64 + expected float64 + setup func() + teardown func() + }{ + { + name: "env variable exists and is valid", + key: "TEST_FLOAT", + value: "123.456", + defaultVal: 0.0, + expected: 123.456, + setup: func() { + os.Setenv("TEST_FLOAT", "123.456") + }, + teardown: func() { + os.Unsetenv("TEST_FLOAT") + }, + }, + { + name: "env variable exists but is invalid", + key: "TEST_FLOAT", + value: "invalid", + defaultVal: 99.9, + expected: 99.9, + setup: func() { + os.Setenv("TEST_FLOAT", "invalid") + }, + teardown: func() { + os.Unsetenv("TEST_FLOAT") + }, + }, + { + name: "env variable does not exist", + key: "TEST_FLOAT_MISSING", + defaultVal: 42.42, + expected: 42.42, + setup: func() {}, + teardown: func() {}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + defer tc.teardown() + + result := GetEnvFloat(tc.key, tc.defaultVal, logger.V(logutil.VERBOSE)) + if result != tc.expected { + t.Errorf("GetEnvFloat(%s, %f) = %f, expected %f", tc.key, tc.defaultVal, result, tc.expected) + } + }) + } +} + +func TestGetEnvInt(t *testing.T) { + logger := testr.New(t) + + tests := []struct { + name string + key string + value string + defaultVal int + expected int + setup func() + teardown func() + }{ + { + name: "env variable exists and is valid", + key: "TEST_INT", + value: "123", + defaultVal: 0, + expected: 123, + setup: func() { + os.Setenv("TEST_INT", "123") + }, + teardown: func() { + os.Unsetenv("TEST_INT") + }, + }, + { + name: "env variable exists but is invalid", + key: "TEST_INT", + value: "invalid", + defaultVal: 99, + expected: 99, + setup: func() { + os.Setenv("TEST_INT", "invalid") + }, + teardown: func() { + os.Unsetenv("TEST_INT") + }, + }, + { + name: "env variable does not exist", + key: "TEST_INT_MISSING", + defaultVal: 42, + expected: 42, + setup: func() {}, + teardown: func() {}, + }, + { + name: "env variable is empty string", + key: "TEST_INT_EMPTY", + value: "", + defaultVal: 77, + expected: 77, + setup: func() { + os.Setenv("TEST_INT_EMPTY", "") + }, + teardown: func() { + os.Unsetenv("TEST_INT_EMPTY") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + defer tc.teardown() + + result := GetEnvInt(tc.key, tc.defaultVal, logger.V(logutil.VERBOSE)) + if result != tc.expected { + t.Errorf("GetEnvInt(%s, %d) = %d, expected %d", tc.key, tc.defaultVal, result, tc.expected) + } + }) + } +} From 61125a818d5a26addb3acf7a9b099cba82976761 Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Fri, 28 Mar 2025 06:58:40 -0700 Subject: [PATCH 163/260] update benchmarking guide with latest results with vllm v1 (#559) * update benchmarking guide with latest results with vllm v1 * update graph --- .../benchmark/example-bar-chart.png | Bin 61054 -> 169515 bytes site-src/performance/benchmark/index.md | 7 +++---- site-src/performance/benchmark/sample.json | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 site-src/performance/benchmark/sample.json diff --git a/site-src/performance/benchmark/example-bar-chart.png b/site-src/performance/benchmark/example-bar-chart.png index 54dc65898cfe352efa7f3e87d5215f77d3ad0dc6..ae48f7ebf5d3d65247983237e26a9a7ee7f65cfe 100644 GIT binary patch literal 169515 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