From 98611293268cb3b3e1505b49214cd53f2d9aa338 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Wed, 12 Mar 2025 14:07:47 -0700 Subject: [PATCH 001/167] 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 002/167] 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 003/167] 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 004/167] 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 005/167] 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 006/167] 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 007/167] 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 008/167] 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 009/167] [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 010/167] 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 011/167] 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 012/167] 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 013/167] 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 014/167] 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 015/167] 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 016/167] 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 017/167] 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 018/167] 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 019/167] 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 020/167] 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 021/167] 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 022/167] 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 023/167] 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 024/167] 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 025/167] 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 026/167] 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 027/167] 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 028/167] 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 029/167] 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 030/167] 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 031/167] 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 032/167] 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 033/167] 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 034/167] 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 035/167] 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 036/167] 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 037/167] 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 038/167] 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 039/167] 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 040/167] 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 041/167] 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 042/167] 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 043/167] [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 044/167] 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 045/167] 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 046/167] 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 047/167] 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 048/167] 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 049/167] 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 050/167] 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 051/167] 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 052/167] 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` 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%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 071/167] 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 072/167] 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 zs7Wsp+#3|}l0~_7-SPR>O#GkD`~s{1$*4V0jJ6>gI8pmkqL#vHoWr@o_zMiZeevZ1 zvjP|TNcQQ?horGYBqWAjeKZ9Tke8cyMmw;)de0PjD6A+68gAp42Bc}+*Kk#J1KJ50 zwTC)Zu*w82`XPbm^5r;s<~DsWwa?N6hH9cRarX!VQl;AQU5`0axT|PCzt`z6^DPt1 zPzWPmBvRy9i)?WJHF~M?9Jl7M)JdW~%XBT<*Vx>gvl7|2-|aI@%%-E7;iuWv*=5Kx zWrKVfvy8J$xz`FHBFU3D$RBGwXr5U{re?7zd`2)TRvKnZ6IzekXN|X`CHJOc zBc4A~E?(vFqa~NEsH|6ORWyyKc%UYnq8NJ1s+gr%qhwT%VNfbJhA1Ohpk%Z9nNXxn z##s6juL2EOgle1Oeu09yT@0OyT8n~>f+>zI#QyW%41b}vV%;6i>Mx_i&Xw`MW>{Ya z56snoW%48%ZWB-cA^?+SMGk>RQ8tVUUnjpv4bj;}98l*8gNFDaZnIN2`v&WV#h@6W z)D56FM}Ju!>3%5O{oxosRNz)z<-mqA@?2@jGrANQ0nth+=_3{+k>nR|=PQhXa!-|E z>irM+MSoEIsxA5RUZ*?tK(kB>+@q+aGrj{^VHhHsXo@JH-U_$|2m$AjeI(9pB``7< z5z{Ib-{%8S#ao!G>+O_Kuym5v{*1tole{ps;JS8)VvdCUj`})}!Act!@R0i)XhiR3 zlgZ0Y3h!iw zkP&hCF+z1-I;sUHAY!{XuHo|0qVl$qhc5jz@C-P1nq7w8?Gmz#khKt5H!>Z28YgE# zK@py!Mq`+;;4RNrmpgUd(UOApmFym!ucV=^rgFgdp2cAnck+qK@(Vr z)Rd!lJ{{k8Vr7!>kaYUNA8Y9_X|YcqHrdCkRE=1=IYwOpSu9Y(QbGb+!o9}-<7;*5 zjeaC9{~h3-pd0kz3}G9* zBFIIr1k`2~wXpR^#6=rox*}zB;D!rbklHxqB>f8rjbKmrgBChsB5x_YgQ_wk=nJu- zbF*}i{!>ZrVNVW2eHXvpXb<{gSf_(iaQ*qV+0MJ$<;i*7vdb(k7fVoo&qx4h`dJfm zX}7`VseM`z$qr}%oZ$?rEqv@v@R?4P5Cu7Hj&~^~xt1|@w0#^TkBc1x-sE=n;PvKP z>DoERVj^_{4r|YM=3~}g09pdxeGn*2B2*BECRsJMei6$3T{DqgDXCoGGzzXnXilauC9^Fz$DQ=7N>|j+ zVIBSDLI${FbW%ZeLcSom$`AYaM3n`La&%0BrWXxaxNmw?H?6gAkhLIFU944Wo+vkP zQnocBFr8st_>$1;=5^*Z9nvto6XN?NDY8~EX8N+0Q~?`(>yvQMf(fX#6Sc(P%0Maxb!-1;|ml zg1(m_Kkjxb{S?gepM6Sfh{y4%%H~4~F)m-J>=VH@_B*JQ4WOR%lOE`(#eSa6$ft-2R{XZ95#cwb@Yz;-o4G9M4-)#xzmHv9S!*DNp+J$(fEjY zQTXoQ*0_Y@goTb~qJ)AD<}Dk7C)XjTat_kq7}0E;ZdYvsI5_%>)%%JNiax|YY*ALO(^*WxGKi(B&oRavE%8a3IDM*?HIw+C-XkH;3#iM5 zS>7~lb3=!-fx=bn%97dHlwBO<5l`-H?SU2$DATgtS=;ad1$3MExN?9lk_fhoB#UsC zAei=)h#P9^_LeF~uOgZI7zXRzM^FoQv?7QuTqCXBvz=(w3)$r}Je}qy{@RD7>gW0Y zYSI%0=zKXl>mJLh-AozMXd=5866iVgaM`a+_m^<51?` zEG|rjG>u!{hRVtOh6ij3u>!INsvHjtc8fk80sgn2Un-WXJ=+|`H;aCJ9yBt^pfNIo zxr7nEjc~jDy2Saj%hz|U1aX32dJO^te0?0>-C>sdO8)9x-p;K%v5MG%@OjPX7X@e; zD{(6Q!CgtQm_RR9ytlUw=m!M3cVS4j@P$KPA^x37~PF2mBzfIk>ZN>C(A6#BPU{&eeXw50Q+qDAmyggw*617^acma-lOJqkJ zpr_|JyKyfN!+kG40Y=YIQ8PH`BuRBSEaFLB!Xq~Zj9<*Lw5e7}PUX+RRyH-&D=eQ= z-{!>-cm8!bEcU6~?kS(eUDN63vUx7m1C)z4GmRVW(k2gn#+&|8%ys&4t&;DKAMpOd z;+WjmNO>&T@#a&ytLJmVdR)K%y{;;$7#HPiezrj|Hk^yr`sX!j!C$-FM&a%39~B3O zKFSWA+PbyJ`hoo}_J^MzNdgnByo#h|N&*~Qs#a+QHR9!=6}u2rHy%FaoRA~FXRg;u z`Io(bJ-<+5`&|2ou;7BAyL6F^D!IJvEY7?DH-_6k^B|33wmZzT-CVf+c~uqgpfewa zAh7+2yI{ES$2SJD275T;xFv7V>$T=LqQm$+dMT8twF0*F^r*4PUR^^yf?e9(Y4k)V zw@Z-Jv@pqDkc%gE&5q>Dm2pwq)CVLTI`u%ao^(E3S@EQ7Kwg>6c&YbG`J`f3|y=9{`2Y?padwbPgbeJij4_7hBZqo`0ourny;_? z;g>EY#Ba{<1yUAhK*7Y;f}mz4>S6G%(2#Pwl-e$)zs+Jm@J>wg9=p5f1I^QMg7xWliw1Jj$9b4&2Lfs-{nPGx-tn zXE5XOgAVq90TEE@@Q~@G8)e&MAMc~qb~TQ)4T8zPP8JbPJcEFv>b=n;cS)V~_X3}A z4=?8W{X`IiTTIK6kMI(#AR#Kag`ynk<$?@tReg1RUa=AEeD3X@K)H0gu{YWZgZMvia;j1D2F#Uw&bb&)kDK3Zok{*xdUE3&27QQU2ut z=6eKlU~{0RSp7iU%a|!vX~iV7oQ)&NJkY4*k-BEGf`|B(RO^kXS;s)padID3J;zIj zzxx@ouqKOeFi+^J`4TqUt~m9F$-elR5T%}W^va>doYif=ujghznIdV5ogaqjmW(7& z=DQ?uK}gC|zPQ^t<`2beriqK1DfRhQv&hv^H(3qUq4;dxFY{pi*~L&4S3D3RGFh|; z$h&y%j=_GGf%x>g6*=jnsi+SFROUQD`A&~dQ}+DjEY>W{m)yBWUZ@`?QP8|4nLHjC zVjp;DaKeG1qaiozCzAX2>>iX{r|^NnCv5@&7PweXq|(<2UjaIfJ2-`f&~^qAV+2mm zCHg`?|@OwC4rGc2CL&xEcmqrfSjMK_g3Cr+ne7Ro(>qe^rK(L4~F z{0Uoi%KYJ-#aef5lNRkj2dB43;8!4`ogNJ3OIHgCGAHuorqQ`=hoGA^I!G1@Fw`gE znOL0Zb}2V53^63N+AICG=CmMQr0muUcXX@~tWgmjCw;NG%K6rHzM&!^DnVL`$r_nH z{XWMF{@9nGTRy>-nzhVTT-#q`@UFYxJDppE)_$K$7<*r4$Gi74F?je_v6(#QJ_aResUUmiSmCO<^KP<>aG zD6!5P*Hexr?%#JAZ31-YH@9k>^B6WR?w1GOXW3ejqA|)XTK5C`fI~c6DX~=w6U%`d zN_~3*sVi286p9vEhaN!rdSrIKie#B(ptV#X@57>%-<%EIY2S7=eZ#lRz8@kAs5o6|)TNY+qI=y5U&1gG6GD2^^vNoF ziWlGcqXLwuuAZk0r#8m_D{UY*j%OUG;W(hWTL;7j-hH6@bZ?Kr4GlB5ce1W+HWT5MK1?{MHKVRYuPhM%ElY!w{9nFtT^B-?Y16Oph{-?Nj;g0_mYtwy;- zfT|CeVEXL-TJv+y{xgtCf)Zdm{tIN%vkUl8B+ow*hXb@}EX<_X;46c3EnHW!>^Djm z*?-1aZH4juX*38d8WQsDJ42j}>#`I$j!B1AXYsDd6%(0T0LcW-ZZ(Yb05)e2)N%QBn21FZRR~)Y=^87Z)~x=Z$1rdDtT%cJ zWW+&grGngl-5M{)fJ=Sw+$Goh3pY#PxW#~t)qH7K>LNG|7R?U!7SL3=V7md8jxz_k z)}v{!{i)h5?~nc}SL;?vOfsErFi!|HVq)h80w{>!ljC&bqDNIQg0Va~bz>lFC>je> z`t1GweZWbRf!N2@9pI)O$#$6J12j)c4rs3gZFEt#Y`+eE0{!#jZ^tx5Jn@DILl_GQS0;^`iex$7SaplhXhJ9e3Zm+ zR2xgH?ltQS)?XDWK_CK1yT23{1IH0L=Hz#sRvtUHlvR+VZ!EcjFdnG|h$3LP_$o5TGiGmgk6(ru)kM zQ>VZ6A2Y+{57WHZwx{uWRwE%W>o~X?V}h?Mm3eMPm`GC0O@l2q>MXy{0$+let?FV# zyYa5#JV{pbVVzM1BKO0uLRs1G=xg`JpbmN zQTKEUvpFU$jNjBerki+e7s^^Qo*;SeckQJv8`-TQzZMuy=)9%oNHyTtX1`mH@s>(!VgFx!}x*@q=JN-mv2q^U%4AoicVQN`15VC0!?P^i%6-rEY!plaBi_Tue^y(At7NUfSySb3k@|6zBt1ix1}9q#{*jMr^q+{_eNr5bQ$o4W_O z5E#;SesdJq^hmsO0F&01gdW*`>Dn;q9XCGn(Jj&lRCt-DLZmy72#i1oIv#1TD8dZ~ zcOGo@x5m~bw?9hqo3gpzW67wO;L~WD{91psQ3E)LA8&1g`!TJhD`g^&m$n&?+)=ZA z#$UQ1Y=Axfz99f+fm0$fOU$%I0KbVPdG~Wc^OdCx;#m&N zNXvee#c?6sTDs{K?RZ#3RC-<7teql}BcJ7bk5pvLCLV_*dHZpHQaQSD%ai7=vP)5k z6Do0aS95x~ZkRF57pa%|hrd)P?g-d3)9%^}T>E@0c`=IU?X)vM6+L%aFbp=^Q;`Wz z&zM!x2ns1UuQ-fLmJu}yT=5S1eBoXEjEBiEzMfaU znzRL=tV9FgPp2f-LYFLGGg-KvVtp48kO?A)ubF$sKtq7L5zgQ$23OT==>~n|5C%9a z(6(WeHnI)Kub#l8-n$VHWeEBb#V@K<0J5XG&4U=?`dRry9PrfLBb&E|)wP-<6ZONfF5}9-^Y0=m~lD+fN(8`)oBbL#h zoJh%6E@m-YByumr{bl2=UdG747VEq-xw;4)$`Xf6VaLlu7v%mI#mhV%N^H3DoA%ga z0ko4}LdKtG6;)}5Y-uNtRx@9O(d^loN?S2@4l_pYnE@2o#^W~Dm#Rd{nbro@)DyuR zirMWzqVnDP1wir)2`%;{Re*+53|Su|Q~Ah#(HSZ6%+!iNDML2XhxhxA=F9%vF=O3h z)j-RtAOJ${?s>SKAX5N^lKYTmy2H7c%stfa||4~1zsdf#xuhhZ~E-nNeCm~)gp+kq` ztFu59?Qh|lF(KapY$o^dyDt<)@fb6#k1E*;{U!7PE^N;Pa2jw;xm-A+i{V7wn{lf) z`}LbUX}EbM?ZQ!}TZsfK5+B37bJ>4AeISGPLFL)*W3tWAt(~GLjJ;Hem=ZVN9)@sN z%c{Ej?xZ_0)^{{U0j=1d7{aEKJyQkhFa(7KZuz$HoLjg59bE+jX81z_3YOC)U!1~^ zJv)jf9U&1yjZD!t(dT?F6Sk;c-{K*^&_;9KqejZfQb`>ytovB_Pt32HNu~Z~rQFHz z+63LG;R&48^exlq2CwbxhWqCoIk{n>bOB*yye-;Kz39K@QHjAUdB6Cu40&-FZLtwDvK*&9BwL*8)Z*{LvxRX^jpU z?Nx6BjSqkusr&iAx{+?P32YF-y)EaLFfljyl3?`(l9-IJL=VPpH6^Y+sEp2!WJq|t zVCat6#9bHEA7^vJ664sd!W_i|q&2Pw$aLB&A*=j120bO-pMV)+Br=9}!ZP;rguXuN z(ob!ZI5!CEs{Qyyc=hDd!#(vpe6ddoaC$8ZodIo#K(o&1{mJ+ni%)?vT-~+O@oe>H z;DzTGOEL!@*z=Pzi5nBy z*KqVP2_6vTHH|83?hMGuw<01EHS1H83d_uB-jM7GZS64eKjq)2Dmv{t~=VD%W7dL~&N0Ja~MD`T_qxu!bA z>M)A?28UW>$m`p$e?RFgl8k-yRGvY&+NXw!|LW!S12>Ny~XBrzV`xy3jTiG@NOvx1cex$M_ z8zS895v=ih-yA&GMWJdldP|PRtcGI?je^!i|wJ0yxyJ_5yRW$ zw~7Alwf$$~Z7B$N$E6_WyLSJxl@yF14hF2RCB93yEHf++K-Mn*Uoe*%ez`Ghr1)~0 zsVg^`_-ev3kPlmag~q)TY(<1^7xw`$g7lCw6xppkEI&*GD&6;E!8k!JpJX+Qy>sq{r&tzSqD1P~ZljEVdbR`tAScd;Lph z)QM919!&241a|%V=&p4a;KY$CI@%ujf2VaggaVTq7wsdVzrWCb$m00>uRRF|`fX#R zR|22X{yqTuxA2v}t*C$e@PA+Z|9Xd9QKa zWB52=;d0@O7T`xMK3#3{C`EOm!#kTXJ=hG9I?Np>^^hY#_Y39w@S|RJI>5^He}FG8 zc`Od1h2JPf_)h64fBYync4Ye6NgkWp{pa0AMnzHfGjm&SP5=IXdLjc40(FHapD05h zmoxpePSq_>gC}rWfNDBs5x~4qElOX$2g+JnEYlIoY7yp+Zn*+z02R$!rPjUmG&l zEn+}SZ#3|s*9f*K(wY>{*yjgCzX4z`eE_tglREAeyYXPGy#C9Tn{|bw}DSdfZVR=<9IJ+UWhv>ty*SdTts!>ZXC?r(~QT$U5%>BGx~J92R&y2YjH^fK1EA{d+KB zBU?r)mvAhd%wb}D@fkyESSXo9+4+{%tE}0SH_|6>xy2U!0lTppviA3{CiEKbTdk#w zf1mS$416;Jy{b=dtNaUIsFIx`^Pf;&PCNlCn^Ra9U`#(>p3MK__3{*W%O38(5BmS& z7Ww0q7V%4L6(>g%N^QdByS}=0Tt#EgM){uer>}^fR4(zS1I(oA8Q^+(INbI-FHYER z@SHS$ReThoSqF^$=pOODpF+a2F%~P}$rr)BYXvGR5X@Vm6}$E2w3gT#64}_!F0Y)2 zrKo4e39#hvRBX8Y`8pcdc)m>6kzskdCm!92p16L`ySI6N>|J5DBDsJmQn3`|ImW7? z_$n^$mK5|#ZEEuM8z~^IIQdgC>BH`y09+-?E9?)o6?~jC-AtD;;CS3Fp0xVGXY!pc zKF51x%zrN{WXAgH|JMO8Wa8BSr|~Xt z_&JH10g>;}7^&>cH_uV2i15tvY(7FRJPiYrY}KvWF=dO_7MHWrpDtSZ)Vmu;(I_X$ zk@H6}yewq0s|rAkb+q&Gck_DcpBfiLeigFd?Z{c8GQ^~^wwok1j441w!G`ug@mI(7 zWk-m=;a9xySgN_r2vq<#4BM0brz#m+X9CCcl>OaRK}zxp zwLa>L5lW&X|5+z-WAuinK_Tl8V1s+JXk%fYSdR#jzzI~1 zQ)tYNfsy%xl8+q8sRNbsiLykqcIoCxO&kMqz7;qfSO`#`;P8E+ee~hG{Hsde9n;0~ zehDVT9=38F#2p9bI65HFg@BCO`Vhs#gZNkI^NZ<@>ijKm2Pa?Df!HCi+3P!5Fz;Tz zLfwBdX3z>;$>M%hnVz<;nqW*D9lJegXO^WRULZeL5h=uBRZ>)j@FkPYBIc{j?|(!A zoAxRb2^)JfF_1CxZ7)!?%0f*BvmY;+K&MKtgM*K+KrhL{n&LStgEq!m9|u=Rrn#AG z5Feeya^K^evYqeWUTq;vTEPB~zBO2d!rPzgNS6JcFrUgJNqs)*d+aWFj(IjsK*!%Um7{Rms%g=MK7&oq0Mk8JZlmL2W#8XV z=?3@{O^T4l*=H3BDpubEfteRIc;_n)DInR6UI-TWsT_ifag!WTG5H;(<T-|hnv>3 zU)v7gYr)u3R@HDD=lw1w^Yb8G^5ouXa=*?2Hk;r~NF48L46CnX=Oc+Wap3Vl;c(!dd?V0P~Li z-aB^TmB&QwcHO>d?!4V%rOz&>>h0fb&!UaVmgmP!!!-<4vlqMozK_N4ol2ZM4#vwY2@tz7QpyJ?e@;N-5%q4uC_lhmcN&~aEz+qH{_UOEJ(j? zVi8OFj%mi$Q6cy#M6L5?%F~82^PC@3=&%13C6X7*`(cTs_La5bVJFxD;eTm!8)mQt zvpN6}1#l&8P;VE>E)qC0_3Tj`0i9c0Z&w6<|g27z_7-Z;sX&ObBaSe0?Le>&PwRTec>;!iu3< zO>B=naN+}XteR(R02bguYNR`zx4Gc9ACK82dkhD7kJ}t$hWc6vVaI$um9k;f_!LHd z{yOwEx$WEw)Z`S?5GSGoxB?~Fe>ICxd_1CKEO4V8Rl=&QO91w%)pp7NTHSy#z}ooF z&Vsz9%A6eY(0p*c@|-fW;7AOQjrQ^527G_QSzNS}PQHQ1uGekbxyU7Wq&8H=2d#NeM*!XHpxX6*vfuo zNgOA(yN`)BE3^)3RQWk^^jtS?OCmf#(leGa(gM^z6CUkB@WKGe!krVDj9#zUu?fg{ zTd}RYr1OIL?{FB|g@oQ0tZrv4?r<^qb|Xy!l*OLa&LX=R(iG}9H@6^SH7xXVlqojXS4*i}Et@b%P;=2CRSW>v``eK3Kc2>_QNptqf(#Fi+qoCzuO}{YcV{Bmlxj} zegwx43E5~5$tZQ2dDQzjFCNy85S{`=4}~$*8Bl0qWRLk#@}6D$x4DURz>TrKBgQw(Mn-m=)dAo_zY*~HmHIu7E`A)H_43J&Uz4|dqpkMwI zmkhTyC8m9wn-2&%KX6$lSZ@0| zvcy>dcNCB<5p`#!uJsA;TxMciJG;Ags#u-kQcNKT?jF6w6zsrSL^bCT`V}ShZ?dQ@(C<)R#r>osU5|r$8H@%*&OGK;Ij*PYZpK)x|A{J5 zwENO$oaKmOV%A8SC@|fS!d|5xps*U(F5SaC`zB2U*E4WEXdAmECB}O&#*_Mp1mtEk`28s^DLmqX6hdK@+UNq5Z%fV8#PiHz0m{1qfb8VLU!>l z7#RUE2p|0CllOi!RxuF*LHBDrakdad9pJuj?g`=npW-|g@Oy3lBXgLVwQxcTMF>lX zBfj)nP-<5(VU$XM98EBHqZ^v1qT~NCB&I7P*(1m&MyKL@XZ=-UxYHa&+l|${jJKf1 zdgL9@#UqytB}x7VBqsXZPMvF(yT4ji3d6b(`VGcu>LG??7Ke4cO2cpL{q5+jX>t39 zGN^|t!kAwBB6rRt73&ONGFJSg7?d)$mvvC>)5b&K7y!BL58ifs)0Nkl8VC;{IU3S| zIB%!n+KK$Hz+x5^V!WE;dflSvNS6#mUa|Vmx32&sXVs&Z)joj7lbUcw_Ay%xP~RH2 zlDa1Bdg4fl&H)5C5BD9T1-d`63m=&s#8A`?J+cxk2;2%Mxq*BCQ}k0 zBMM$^_z8wb7c*c+>SRKpvF$gO+V&~K1Qu-Wb9TvV_sUJrL5jvRByKupOAHp0#i}lw zS#C81Xh|&|m$i<1`rAa#C9Z~j3caXuf0{#{cJj*Cqw9#yPSypqR}70h6a{cmCCqbL z+n3G9Zi%a%=P`=Gzk`L=Xw5SFH!SXTCDauYHBa#lJy>~qp=VyhM)hFVliftf@(J6> zRNf&3m0#3*TCfEuR1}D!FC=Hl7P1eO+W?fb>0Muss4X9HBPQ^J(`^Z|=oP=&&j9s} zIQ?H-?IltULIleE6$r__LF16dTh5+)CdV#`$LwZeO1YdJ$`m2S#qWVipGp`(xNP>4 z8vOv``#pFIVwa96;#elxii1(fNxg}Wwqw4!VWZ5E_F=|;>Ob+jej}d;TuHfH`@d}` zg4|;V+YNa=ZWrMsM<8_AGH*;gIxfY5=%&bx!0E#Vz;VkrJ>pdEBCE2FO9=O_MehMg zXVIfujGSV{OevND13=eST!7kP4MyUP>Zu%)hoL3_@w1V};A`8VyXbjrLpBK~bI{Jr z0P{{=S4S<*R9jnh$wFyNP6wY}aV;OMa|+We(taX1T0UPLzAUjPUXDHV*`gvOUr)(9 ze$(&VDNfc@3#S$6jto8o)U7yYN-p$D2<#i&*nM-1}mj&ds zD=ncvOujPU^D~2D5(L+5NFQc@ngfN*#KCtVJQ{1Kk6l>y@_C*YMHE<_W4kqdKqE0J zwlvj22N97fltEh~F;nFZnk|^p6WlAQ|l{uC(H7h-#BNH(mCeh<(y>H_T z_Y3*n68gV^8-r&PiJpOD!7<%x{|-3j*?Vyp{3~LYTMFFtnrjy-)en}$of?2x0;p_<3}D?^epgh<|k^}~4n_t((tr&BjC2rv9; z%|O)^4%KkUk=@OHj=K1YL;dOF?0sHvk$f5J^~j(wEr+=ziJ-K?;chlU>6Y8jh317m zhS&cQy;RhjM_J>wgK1BlUuj?oGEs{v?KF>C0kqi=2L~q-X3Ti&8*_2GyzuuV;dNWH|vGCAaXHSlZ{z)f)7SR<#+)fmg7<08l;J9 zBelqA9r1jl1@OHmHx~a3TMmN|hjg*JDOoY9UxhGIhW)BRo3FdD6)Uz=oS<56aKPWU zMHe~d-%3o8#vPnvuI_8$$-6x2kLXvbbMdPg>ruu|RC$G#g@W}^s zREEGEU{0hA;Dtfv;<`=(8B_O4C5lRsEECZZ_h%)&EE{$kQi+Z0F{2T_2;)KrXcsSZcRyT<&x#4@@##-U8@K8N6%oGssHHYc0 zm!|DJ?&@p7vtuOLd&`oe7c-5MwqVySV4qa`2j$ z6?(5|NV56+d*lm(F2#pKfyB}B)aQ?XaVysR>@Na9Q6kYG4jX(mvIPURQ!6A3sXjC- zQE;t@`r&;15LMvl(-@QvmutG7?edaP%^x3hc0h9-i5Bb z_Nh1y-?cRklaqC5Bc@_Ntp~H>;aN(%Q47c&H!#OC=|M4}%!h_^_}LA7MQ9eU#P~06 zzibxHsAkV9>h$sC%zSeFaIaS8ZRs-eMr^j(N3%}&dHs$c4ppWlpV%SAIi9IhxLF8Z z!O_PSdQ_wnl|HmD*iK)j#Nib4dDHU9wQ8`nRw3aQ>7kd z9bYp2uAzBcg`;q;%-inBC(+1oU;l{DWX4Nh^qm_a0zWomx9fJ&O!c`Vf}zv*s(j^` zwoA;3*qKC|W+kxpQnipq~1RaM`osi!E_wy zhJNPsCI&7e-InBvFQ6ZivvISu%sdndM|YJ-{_^@Nso}@vxXNhNxnj5Et)$eb#NL)0w8282 zfGV<;7)c4QN3!eP%UD~jjKo4I$qGCssFG^)qvVBJaKeg-leR&J%M_wS$jA@x=h_PF zZDGrV-bhb~<$p(+lucI}g+gFih%<=4>IZTG|92!tq*3|>904JHc~oCJJvvRi^o|uHAz?kT^NA9$gzzq|zXfLk)lCN!%iq*_!&a zyUp$TLE*hOO-(9BxQM0DotGqXdGWj_88Xc{MDgB8EoW_$58V3|Lp<#lFy;N6DZO9> zmxfU>n~Yv&pLX}dP_N8gkIq_p%v{e!j2$B5eQgry<4isS7fg z)n~RT9B?uOD{M{gjiO+o8Ws+W&ubWs;+pUh58H+q2Ak4dF|kwZG-JIL6|Q zp&aLN5x%a}tH^0!@r^6W#lB_$BM3V@Ly0n>T$jM=r;f1t5MC%m)#^fSeGlI-YU3C>tA~yA`^HnAWrD)X9ZeHkr(*oXtH8t0A!#Oi2*zg>#3F94N% zlFhxX#285t#zk!PV+dW$>AEUizktc~PsAzd*`ml!UW{BlvuPksf>`sEfpOut5>+Jm z|2Hi21)G}0r}45CIhzi>JhGgAmRN3YDoTYDg?YnMv z@@Y^TsWNa`#Jx(>J07xQDNw;&^WCE&a%W`(oENFT-)HLDCtt3>$#*{D%O*H z>^2IauKPZ~j0`h8^5Fq_dcm2MvHt1#@dNb=!ewiN2~dYlY4{ zT+lTN4xu>qeG!*_rQ0Y4YGu_pnUxM=C(k+b0wV-eivOix#2Q0=WRbe&ebDP=8$8P? zCOhUH-2suWH0wg;N?OGA#3i@xDAXQJo432;lYwwzZtpozFYxIGScX;e#yG+z8v8NN z#J7|rOR8+rU8OF(Uo}j*s|mlH;g``$Rm2}e+ad0=M!M3Wo!|8iE_`!N}?F=nVq*KaTUhu;1zQwZSC9@{V{rKvNP;A z`c(3yhI$>e0t=iBn+#FAm9Kl5tTUmTwDaY*J)5MDUFV*!+4wS4QJke!LB9VF;^%IE|>^ueIhjT33ZH{%%z+>Bo7e89x@-C1jOpqi`& zOq}|Em^ctVLgo*O(q^re(-3-bnvvqt`q3O{Z9%r5Pc}YuRK8T4E_RY$`M5nU+UxRw z>hj5iVCmwb-M~#h>Bl(5DxkCfl59$SIWqDM_vfZ(Q&@5JP3RJy>%j9n^rtM;QHLhs(o5?NYFIQ;En-3=1sSw z+g_+WaVuOCp;JBnbo@&3#TsoW|ERy1=TH!)aQ}fcvU229?!Q!<%#(=GItuI_&))Nv z$36ik5d2q4Gj*b4~z_$%o`9Ei2wjASdPc9i#26nNQVm3cOyS(SJ(jHO)Con0@pWP|g5ygek4W%-)N?f>~X5N$NM50-*TkfNfp%bq!*>yb^iL-GbxK%Bu*cJOPpYbX-!%B zNY@g*ptUF!NucwxXo+RTnY16e1ToML(3Z4)YeM$Tly4+=I7x18U`&xN6Qt9jH{(OBgtq-tvc>-;+e{zW_HB%906c#fM&i!oc|>nswk!{$vbIsq3iUfH4IkX z*x^<;SdnFI2Ci~qE1zN=a}G^_;J`~NlxagovDb&0Atwo%=!{yGsnD3Ajk`IMOdwwF ze81}H_S@+P&>CIKB-S;Y;$;fhO-Z8Xx*vJ7#tByj(1@wf(Fm*ARNDP@LLmiq5w9Zt z?b*P0gifO*e%aV?8h4nI0?)?wv&^py;#Aw+5*eJl=Q*v#3};w)6FXFLGLY5j+9;&T zN4OQ~il7+V*oakk_dB*w=hSDoYo+g9@($%=uh)TG?}h$2pJMt+dYR?7TI>>Y+bnaV z>%&3@O4_Y4(Bk1E0hONhW|7Kx<{P(mS0lQeY^~15(p88W;gl?;KrF76=6mijVx6vP zER&vBP6G{>zSa4`ToE4kvb=8>eb!*LC%7(72*D>!inDn5sGk>CI(V7cFDgHE$5N`4 z-Dxne?to)%#9k|vDF*%~fUnmDKN=y?sIEET`eCPa>SY0~H7WB*=B^3Z3n4KQTxtC_ zT#x&RxS!1XPjU{osOfJ>6F=ZXpZlmhew?SC7L05qc>!URkytB;xJDoWXRApGa%S0F z2D(5}{#ig1aNlY59;`Ink`+F}rI_**(Yz>r?tw$hXOixA$msdmxs-R&acoRFpm%%*q|3^OR$TRHHiVBK z-X+j6rPygqxmE@qlR9bdzG{iGGI1?hk1_!)LuhpHK25fhH|=_Y?aEcdn)zg7?^Vnp zh2aOo(jEC<0na=z#Zzxt)Qu4es|$>V{Q9%U0F3%n^8qaXMm@-0QA^;f$|sy%CpURh ze}}3EGH+E>E$1x3I<*JYt-FkS@*$GRKi^^w1o8S<+gIuYHn*5M1(g$?*b*CdN?BOQ zOouA5Nt-ydsKsihp3~j=H)GmCf@pQ2W}ZsjM2xZV-p4OwrJO@WdwnBG^IZ%jcn#V#dP)=_Sk$5s|k2S z=AcWUcQSakX7m67>?=ZIhz$p3JokaunYh?#q*&&{_r+{vvF*X#@o+~B?R$s_t7?yS z!Ni)&;3~{;2~u{eEf!q&+Qz-sAa@)^64rp(^KNU>%VG zU|UqhJ(BSCR7uU!gMoYJEl9T~M=a^=NR`2C=hzb2<--!8bsA}&tRQ64msDuV&XYF= zyNyC@O8!D@RQ`k5_!a$dR?>#5gDUtou?)>1jCjTQUP9`sY*zB?Ry{`t+FPfQ>l)XdstH8PM?bZ1dGgL=qc4>*LhU zV{cm%(%=WwvS2xD|{dKKyzrFnJ6Z^^&F%&>$6q1qvk?W~uxIVp5? zH@dEoGf}Sptds8^v=7k!RMPv+-iX=Jod}HB;yY&Z=w;+Hx&P}dDmZd5bYIq3HM8$$ zupM(L7D=}=&qL~4ueu^SvrKGRhnBR;v0E4 z9|oIJJFn;?owQ>?g@MpXd}8lSHd;+V`OdA!`1dDe3nE}zq!kOdk-Pg%FXOp!!tG#U zn95k7EA=Htk72SealJ5A?Gsp?q0^vrS^Xw7%{dQc+VH#LxIO#Jv^hWO@-F%TVvU`J zDAOjn{5Uqxi+lQ;f5OI|Ox9k+E2U*&wkEpDo@3biN9ls;l*QKb%OYjN=}&yyHc#v3 z9;Z8jA!=T`d0vk*zSX?OstzbX07XqdI&J4RJtkXafv#X3u`=Sk>I-7QQmn6Ql_9c4 zuy9Qf^(ZYR5C_>i8~M_UliskYea-w{^WGOQV+HQt4+(kIZg*8IZ<6Tnp2SLG7Ni6n)uq&8i?2zCG5YQY1mDY-S*cAUDRSOC<1xoUf8`yO1=gyOaAUD8w?r^%*D~ zTtDyErNz}ewR&`W``-K8by#c%wOTBdYFVjr46G4auSszo@;1Ik#In@U$Tajm>(+|e z6?btS!lSlML>^(elw-NnV=vJHKa#j`ffNT7-*DyD$QoD4l*hSF9hf-BdgL)N2a7(@g}cir)l!g91ox_npODZ$ zQM?~nD}T7%a5fJrK=*-dqmO@}k0ULWN0yf%%lM90CC$@5H!8UR>?cg!9}a1e*_GRpka`l6T{g%;`AfJINSOul`6g^=;_MCYJ0{t&c>2zdv}!e~l# zjg+nr#LzoyY*`mVcF2d^no8pB@|6b4AI)+rwGTzdk#-7P;z;LjAU4uZKSD9=HD`ic zKxN#>MCgbTVnNgsg!jlr{Ot4rqJA z&(Du=N_^BSxxwpc=ktL!I2rCNq@GGS>Na_f-tn6Djk(~flbfu=--nRS zd2B&xxuJ@?^OICw8U>EqZRbC%t8Yi{?WW+1oicdSTDBaTvMjxN;Hci* zC8CD7k+Od9h3$rtO4;K5!^DA4WMX@2VZ6>pAn|{&>uILDkg?#q1-Ki1z};9P>M(fV zS?p@*K%;{U7g{?}D zo;33?S+(1_^W0Xjc%qofWLX_u#YD;bnqiWP)Y7TYK~$?reK4!jwM-W0zD=CGtF=77 z`BO8SztkHwogE$d(ZYqNMj*nWTNlGhLK+)O<~cgRssHVjl6WwVwC7sT1$0g(luToG zKV%^oe+;iH?vB!3dSc6#R|myX)E{m!d$ZunKwaQP&@VPAlWx@c^bBS^cVf(5@>Yx~ zD%&4G$-4y`lfi2CmU!u2x)4byh!&B~0kpA39+PUGJinfisY>oTe6*WS)v*#W3(Ja$ zTx#l#$h$ax6r2|h+sDeHj*D>GVuC-1U8oLdAklbNq|=rbDc{Agj6Ow% z2+@B{styj+_0Sx3aU7>DI4XGza+}Vs(SecW#!R-D(nLXrCNo4PFrBJxBXVM3-$*R~ zzxIu4-=f2r{!n1&ZTf7frzA55ps!Io%<69DakJAPnu1W>_oM<_gK6FT%nCPT?_+=2 zmLSNNw{kME%NhrBqY26h&gg7-)hhQZl_ftqAu|t6<0tcJttqaSQ-WG*zNFz7;>`)Z zFH(3|SZYm8^(`$rc-CIra4jX;dXl@hn&8+oCvtoW)ESmk{_s3M*alqtfbQUZ9GJN^KaJuyO>_~@nsjF~tas*T# zfKjtKMcdNpd=2w){`fq1t9Hg){cXO3jjKEF@T1!w;WxKzOQyqr;-d-Sor(6otg1ww zq)IO4CSSKM791laQA`({5c^sGXUF*Kpm(*5n0x@eAqgE|=Aa`tvIz7Pl~klhZT9R? z7$4_J(fIWijsDQ%oD=pr6>R*Om-PB!0bjr*#oGw_JcqyZ8->608~F8+oM)x3bQtSk zpo)OP-}((1O20uMwZss1?94f)#3Qvqyzf=Vb|#^LGKuy4AYGX*A7?`P zEa{Q@5ZK^6o=KMp5ds^4-~>B8x{jR{pxEL!MMo*)shj_6RYio8IJXq4^zfVihaT7Dop% zp=mOM(Qc6~O3|OR5ae#it@fKvkcI0jhc@I9qL53S9|y*awQZ#S z3vDIrcv7b`nHyY*2}cixQ;6sWM)@FM3KuJ4jKsjdYP7w9|9;HTqhL5apm%W0#YS&# zRpwi^N0Z~QjV2kk(JZ`CQWzt*Bz^V0yNUWB?1W{jSEI9y6Me=u?DNsY z?LD7Ix<4h410;yIi2)S7vJ&bKp;;F5cq|)u7j!fbIOi0{IvX6wzCjdxtXm6^NNJP6 z>y;KGaxfYEIDgp|)mz;?R>>3TFey$>A92b<`}vmNID-r6AjAw~aLZ`IS>`m9RQsdi zhiGZc2tSdm0Q-YoZ|%v-#U-Ru_;L!oO*KJ~f`N~g?O;B~$q>;}-d)S4nfL;9*a^}7 z>bWp<$hS5L9&wO0>QAh@5qr&^aQ9kC)gHmsQ&_2}q zmcpPV&O+qC-9gPZnoY}}to#iG41VLi8!;!Z`FkF!k*{$^vXD~627ju%7Ch;>Cj7N< zP&7=?;H%U-8MKi>hj}-+4;!?y+VFaNwH>cx&F!0Bg?=41@8q29w0aW^N%<48@gGkL z3~dlC&Y|YlH&Q*C6>n^&^BZXzLUUrmdYou`IN(^LNF}JW_eBUh78_==!$UY zl?WFBC*~*IexP4TQhU=^ch=MZwy%ybfU^@Wb{>S}({y|g9s`RlSgI8x8XZ1d_=~%4 zVOzqppqvQ)TQ9=iTy$by7{21k;}*!uX%VRCfZ+xrx8=ic`=Mnk0KT^dSPK%uy^Q#&%Ow)1stMaG+(u0AwyftPk6KUDCRd+hB;*i^!eG`ujM%5XE;6DV^s_H(#_RREaEvRL?m}gmomXZzB46<^m3A z&ZF3t?+Pjlgk^KCLyKpgCif6bgRvDM)QWF=;++RlZe(6x(V4F?pLwwv^-zrZfcLvw z-Aw*ycQZ~_TgszdLw#sE?=8Cw>ANA1Up*8rO;a!&eVdx^o)m7`H}RBOkZj$G=-~JF z#8d5g%LoM>Z*=bC{E1g)c|M_X!b-1MJXK$rTbOj#b?FzPZne7q;a0<<2M!4=#P^?O zj6x0(I>t zxaML@Cs%T>njw2ZB9`L4LJ&w$tFQUijit z0GB+cLJk+m=e?ExuoU&~Cqn?ad5c(8A=G$xc{^uN2=3(iP)3XDkox1F>&Sno7#5Q_ zDGZ7AHHY#{3xcLC!`GY|Lx(-pqvHCJpS1ouLaPO zXmN;>0h;(`S`J3ir7^w1ESj1GO$pKF#h@EuX;mPcCV+}gRHF=9Firk4EbZk#&7ps) zJ-@RvrVQXnrz7r2{QE*N3!(!h)JF(-vR}J{6zTljW!9%s|J4)uCuQ?ny!n6$dK=Pm zzViMLfA+`a{~!OsA_%47VE*50I6=VTQP*;V{`+wLOhx^-)9}}i<&_3g`s{=Jp>{+6 zxNiUZ0}+%4?h^BBQRJWNvi~K^l9GeENu?!`HUD6g{&`pZRt))n{e}4~+T;!WG9qb0PoB z!w+Nzr*0DSb~xYv)o&*u1^29q#IQj2>G1cxK02PpyW-rE+YXvTWOUZY@aoPf^{x9oHPQ$62eB@fQ`8ga`?crP^PIAi|n zY`TMxMH39*rJeyxtR(TDi@^h#OBasD1ZlnrLeBda>ynZ_t8t3eD1+y1zE{T$v)GmU z^%avEuY<28-kYx)@Oc{BlFup~urF-pQNwtH1+A z8TmFE#d@3uYMYtrA@1LyP0=?fK&A#9>du#-@1MZ4V;=%4HoA--H`*Qpw7~zRnBP&U z`TGhGV7^yz-Z?2}(RBD+edzq|{9d%L27+J@G?ApHh32f(%S?w|9f1FvVINYHvx6R0{l|tD%-|O2iw?EZu`g7HPc5Or_uEq-XRN^I+ zym<0-B}(`KShOqdA5LE2K1hGuG z#~Gk9eE$wioM*>Aq~tEb*ftmql$jPaoO?g9N>~^W{)q= z-&XoL)d3vG?gn>*MKlTK3oue$`0mxul^SE`R*#oI0eQ3J2qyjYyQJD>dF}7_$i_55QD58{U{U0czfpfIh%#`LXLLU`^(D7{x1G6V>=>s_03#eXgZZ|j zx&pn#frI9+OSz4*JN*_h=r;Vwm%LFqXjGk;Lj$)nYCzDS(2Pxjn96M9N!v+l=vez< zAQ!9s4RB@-yw1iG=li> z%a8SKNtv#LY*EWDIWJ<~v7Y4t#!lP^OkEix&j444=X{Ol`OFE$!gm5RS0Go} z0ijyayD?!QU#TVmshOvgvcfm-)kQTQ8}nsD_}QFOJ?;?}H}*Y=AC?)Byk>3dSw75@ z!mbaR6t88zM{Vl9nSY-4h;cULBLE|*tGv@jf1UigGCz)T_GM`OzMxAQxHrEhSLDVQ3vHm=I^9@W9^=`bvsfTbj78{o*mrH$6@lBXMS5am_C8bExn3BlGk^+JZfuB6^+kGyERi< z(ZtRYvhgh48dST1c$r!HMV8<33`Vdlh+Lnv2@kn7@3i^UfhPN5S5tD>tX5wWV>sJm z8tuV1%lzz0krBj?%BmFV0AY2KrRzzCZ*xYr1 zNt6#Wv|$yjd4@pStv%St7Ry4mAfZ$5d7hsR&8V8~6JK(Qj~5kmEB1#v`+YN3?LsY* zD4xIkMGq_F@F;nmVc6kdgcZx~aGts+YoX_-og@+3=3Npa+qa`isDT@-`n^nNoEP^}4`syKA|DBN2I(bhN{(5wOo`r+nUhKdR&|tOzVbXJuR>;0N zmLl(~opp8i%5K#SC`VDrKI@#{XMwci)4rlia>)xOe^5-EH2(Rrd>#2N5sq2K)elE- z>iqlx;`6L@K)e!OTYAFe4gM>(D925Jl-4A9`6oD+mQNS_PRM#C$+#6{KLOG(ljOBO zcyq7)c5yDGpUjO!BHXF?3>qZCu?%I?U!bO-`h4g};acwKlQ^#O4VAz8knn4^fC5(< zC`@njsNHNrEtA7s@bMt&hZ2r79Uu7`8q6$1$@XhgZAMb~7|a{ISPE0ZhuRR9{6FT%=7;yPG?Q^bh%?U1v-f-N@ssVX~ucgK> zsNj9kcYlaHAS2BRl>qkIy0Tlg(DfZH2!*zqR$DAE(|FBW6MsQFCHJMw80F<~Nl78L zy8$6uBYVgoazuIEEc;1Ar^jF)_jf%$}K5jexI`5O|KCbNbrAHT; zwa)x=sWW}bTf2lfXmFNiImrD8eE*xsxsD7OB?dpB1T+#zak8`jLAs9z$&Q()K($Td z^xauIv3%*_hwv_bP)3C4!Y2&WK9b+ked0j4RG_pgg)=b2Pl>rvxw#&$jH9yPNa|4J zm4esKhri^fZ9V-{gCq_@deC?3&WY`*P#d08%c!=!0M<>>J9lYUEN?N>fUlw`4D~Nu z`N~W*!mlbdyy!Ho)RPz7TlS;42?*cgcoU1AqukQQ$Jl;lw{DVeR(`0=v6KGrET4{v z;IQfbZsa`Eml8nnrnsjSDH|dFy!-5fPnW=z%3CgxBP|35`{&}%lZjkt>EG=$6~r(m&3y+EMAO$x!wUC)^Uby z9_T+mh~hOtbx_n`27c(NSJ?g0siKD4%@YB`Iqjfv`RqoeLypNB;_dOtGJI{_>FO;T zw&V55g70q!cox!}!{X`5#CjsM;@NM0a^1OI>7`DKNhm(ahP$=`zEn@0Z!}q!8}+TA znMMArgWOZ9kAQ$Mg414X9+|ez_b1ug!u~+gbGx${n)ITD3OyIgN}U}{2|s7$+>5=% zREK*hhB@WXZj}LPUjFEJ7znqM)}vb8jJKpsDI^E@fShdmfA*xymde3tyRN$5tT;963=xj;I;cM#NzdO~h1+vNG1StjPC6^QU)q zdPOqcHJmEo!GSO~45Xi{AtSQ(M(b2=7gUtSu_E*_2;8NG2(1!@Off^`1Um{ibB8O~ z%YtMn0zkn&Ivnj(eE{Z387EDwsZc`aoeW}vb?dem&SIV&6m^Fj*r3>RHWFlLn@=bq z(%e8JpWnH|0LC3)%K(8w~J;Wi@rb!x8-UcHh+ zH9)8o8%sMge$$oHVTt|*92&&Gm#C{TFgo$v)6d$2ep~biDYOrk<3pbe9>+@E0(*zs z0)sGXf_+sgnFXC%t_3hMCELd-I6#RGb1(5?nIdK?_E6SVMv{5|(>=S)r1b>f34(YD zYqpklX~!trCeAkh>EwG4Qh%>P=bCwZmu`q|l3`q;-{6e(`5c7FAftxFUReaDZf5($ zv-a~Zo~6$;Omm{vzRSPu*e9$@n)DMsm>j;Ap$zKs0dDJMe?RGT*LT97tPotT)nbSztd+H6EucjL zwieYWa;coEMYv&2>;H2JpOC@n`U(sTn@9VqP|Jcmp*a5n_Fbo+%3fz1G&!~5&Of6_ z{xE%@K`4HxH9vZw4kb*&y@XrmuDvwow!Xm};&L5P1Z7w}eXF)rq%3*9W!Qdal#uq} z>3h$JJxs3xXX}eJJU3e}a^?$F^Rw{_qF&QUkAa+>^7Sp+dyE~Pnw$Jzt%h$MLHS0C zb|_X}yK(!zBc#FigU!`hXB7R=;vTq*_!up+9C2}8v?riw*>UYB|H}u|>J%2AJ7miJ z`9TdAO^{ssg<)%WTWnpZL}DX`ZA@K^L_s#WVQ&uy@#hAX~6G^I?8-GU>hz>>B8BIA_D zTVw!4$kL~2E;T&d0qhM{%x1oVe{T1I6Q~oCbGTLtk{Cj{5ppRIQi^Tf54lizE!d;C zQvQ~dmt7lSGPKG!^E~gM4NcoMRfpg!mC!ZCxb9x=Jb!*g(gR6Y zwN!0K)fUTIqU~wMdsmMiu`AXUS$Gi3qz6??%bey?=OTNI;#BnTEg*5thliqUeeN_} z5?PH{xNb%5hF7Y0lhp^G?}kf?Jpc@j=5GeaHDBjm*14B7@e*Gc7yRE0 zjsswD@CAM|IE*L;2mUvMqwP7UqnDF+7~`i!8TPDjILT=7Kpih5HtsXwr?$dTe!5R!7w;DIpk=y*!HRkcRZI`OX0&F*Wk`s$Kl+$%b)Lol{3da0Z<6q0}Sp7US|uPz1?(oQVYr{+Vl z-4$Yyu(x?s>P$x>ne)zP#vVC?pGx10*pmYHM(0&ay+bg#G^;f~pe=(a_om=)_r~^b z_r{Uz#{a0^-1%F*N!voz|Fe95{%0I9haSZErWi3TpI9Aza!<6^Q%%}SE33)F>!KlU z{VK)$VfDtoDjCns?lB%#6Fl5@BFt!O1v(&NrYLmiQHTYT>AY3-? zR>s^0R>vd;&25@)y+QBTf$qp zco6>UEGd|K&@4G52TE?n1@l+h0`>cG5iYq(ha*jfp%BuWwr7vaK5hp=1kKhAcJ2;a zo610X*+>;@odHYd*`}j+TB?Gh()xr=Oi`?@Usm;V?@jc;2-Q6=C9<+kS*=JiFSQq= znv-q@Z)gEl;dN}^*MYVozD>MJV9=aQREHCSCTASDyhW97Bq@K#@@MoU31V z1!W{wJX{7M$6NmH74;kbltUO%u_h}6Z_jbmGutEK9R$lYd;(S~UE&+1p?g~I4xp^s z-Lff5rfglr26wCPizeA4Vbl%8ZZ+# z`wp`&*(IT$H`C+5j;G{_n0PTptj$1sn@nIM#JBVGEl^y~9# ziRoJLoOzihEn_M3o)o*>&|8CjuZd!$dmkJ%>w`hK^Fr(T6UY_a@%+z=Ujj}hJ)^w) zjB@R@WfP8boT&83$?Fcf8iM7l)sQTdhVU1R68Q&~BtdzBgtFq>f}bYp#(7?Ac6_+o zAFMv*n)3{?N;2!#f323o^Py!HW^Sl|sKl*(EaE^fhvU4i2Q+g2_!`pqS zUn3_r_+-k$$W-g+SlX0+Xq`SwOn8&Mwt18JM+B$m0y zi6<5p)J%2Q9_y^#3^H1i>tj!M=MyEjwo$?*UzUm0nbU|qHda(C5bKtIW;HkfHOyN?|_>K~Dz#(yK@ov_?F&i;sq0J!-i6dvNG_V2g`Wv$`?MKCnX#lO` zrFY*8DMPcu%WKif(#uG>LB1~tJPT8VVjD%I4DyQARFC|eD9Gwk#&UFUp4Ia-z$k>2jc%nT zVY3BDTHDacA64Uek1MCJ_OFW3g}rgM!beeYmhT|RH$c~MbfiG#l$iZ$K7v$Hk1|<2 z-;QNJj=%3I366-uj);k@cFYO1d~xM|YX5GqYo((t*P!1Pw@0W%v=bC&1N)C@llZ4; zqmrlgKbkfnT~g7xjJG8eG<9ql3&5Kr8)`j3rSJ%MP%Y%DA`0{sIniyt&bD@x&s-Ac z`^i_ABQE{vN625M4bxDbV-fPeG_tN65-o!G!K(qf5-dK)(f2gzkmB4rr^jCH+;o*s=XCY;iOSFuU*;^uecWkh5Q*@WD5y&k zas$i6_w`@%+57S=iNz-EjZJ%nYiHt%)~@kLQrb;8$J;62VU7WE1bt=f zmkrHbExBJ1Chv&=Xt$e_yxYxX%g2h6%rE>*QeQPSErd9CKxt%dp4w>?RlzowwPW|_ z;~y`mLAhGkw(f4~Qq=Eq*&Z@x2{|ZqF&n2X-A?q5%u$RZA9Lq;c@g;PN}HLUjUL&t zY_w6Vct=zYgWId1W$XbAsmy9V{H-Ii%8ya|>0XwHnT&_IH8oDw@Ep-D8q3$GNL8Z! zkp|os^Ubg@T@8Km4-OV(>*=!JAwNREJ0hi)2{ba{O{_xK*FX;9h|_qRpPjrc5#FoMtit~A#N!<=ewKAfeE?JKf=(n_be)?6|2nwvhNHje-yMEHf=>8=w~ zr*A?}MjFC(8{qp7ukc474O5GM5ipCJCvh$Mdg*l7r9Q#fe(TRX><3h$ElZBTHpYN3 z9~v=tyK0$WKOkZGd!h#2_~FlGPPCHkeBGI*pp9c;G&ONI-GLhAg@sJn$`P6 z+JRUhL0n0xEF{dcdrKy}1Ms|;Rwq-^fhp*D{3ZRt zH_TME(BTdCiZeXYE)nSF9Fm0_Qbu)4--oYM`T8WnexA5)z0fFQ8N4n`jk& zuI<&iGr*}`eE(}8mV9LkcMdG5rDsbUA4j`t{x*F?9eF5+jsf0IoEOWtiXuw9ebz4;jz*vf`SNB?>Oq4%#MsMk0?fa?#&t~%9 zo=wSb&&C^gHtUEw{1;*4?o2`oACe62vuQ?p!aIch1=>bIJkmI&kQ+z3>jz?khnL#Y zv-79YujY+}tT%!`mV0y@@ywq_n(5JeJ=&_puJQ-*e)~#?dcFvFLMnu@T82U!3!_Tu z89eDL&*fyhWi56vXbJJ3I#5zmuJ1<5DMgK)_uO@q(Q2=4B)>Umn%w%SsFzRqUKZ~U zR4VV($)L67OkAamW3%M!r^%Stz5f_CiN6h-yuS>aGn8R-i85?xv}qXBW?xE~G0{O< z2DdD}IHSiY-*~CiY)>CGD}1)w&<}fjiBE*78K_^Np5~$<)tSr$s?n6 z7^Xj2DT7ahCYm|qv6r=%i4E#K$@`+&5s;oj<1x42hz;pK5u2!WfY@k40Y&YLlJ<)t zt~~tcsu#36YGJD;)neo3Ro`B(y6F{&K`j24Yu|>I*1_{C; zKcc^QnZxtoB7o%$D`lyY;WgYCdEsFqBWTyHT6XI=_jv`$&w}q$oU^I^$c6_tft>dt zqm|>mKsbeEJZezFS~ouFLr>P&IrASivsqJ46Uxhs5NCXdZ{C}6vQfN#Q_YHZ7V+0N zuU|(*V6vuZN5C3h#c${Q5=UUVL5&a5BS1ova?-%aKu3 zu;nK~6^<=a5E1Sg^{JfPkBBf$QYX~R_k;62IyvT+1E~jSck!ce@bLapY-pZ-f*O9~ z%gS?0lx5}1k7t!oy!Y$ym1L&oV`XO$P@t;W*B;fO>wkrB3vHm+4=k>zT80)rB)8!x zOsB<83a%KWT|gH;BOXjNKy1zCSnG);QmEy1hC7GA<6Y%u8!p!U7#ji&y(Vtc+no&*smBr#a3k7k7*Y&L2Fz&qyR6>&Hw_?+ZxTyiNiG=7g zi9{Ne^H%QrCfR|Dq17UjHO~Hgkju!B3G?U?AwAO=4}Bap+Q3p-%}Cl>((=Q)L!Z(8 zC!se?>!r!!hv{DY!?Ee@a(k#)u*!jlz|NU&{o!%?568w+0Z(RD?@a7%+~EBCa^N$qSt%0@M2Nt*PZ^CO(P}lG}#wk!AtThKJTUZ4#|# zpz6H{D@c^CtIuIjoDW90>2daFJ{cik~_z_-w`#qbq1q!M6sD^8p>@*(s%BC9M%0VI9OiyxeQ z)g($H&ra=eke0UJIlEr{5bj9-jxxDe{vY<k--T&5&5=IGw=)&luL??O|CDEd{ zh#(>&(FLQAPND@-6NC_%h+aoclq3=*L~jvBiO%nuXYIAu^Q^tr{_XeJ$NT>E{^<}S z?)$#(>%7kM`~7_Jjnnr(zZ*^wFZNf{O}-w|N9}C6^lHS(ZLsmji{jz zAC@JBo5(Br)G8@IE~E%N>%nyMzI@s(GJW08T(*QpE|j5&rO*ru?prv`oPtirT&=S`()l`8>Yd{4Y`N21nLM zS3JGbQ-6HPznF0Ng+Vf_DA`;cS)ej`<#coP?s-KMH4a*X2^8TiUi+_0{vXhqqrX9G znl3-F;4;ipN=Ez(ZVmD~5Txz^ZjImWBJ=1UZvh2|^M8_HT>z4$75*QvL9GbmE;=Wk zC@JR#&q1R*S$}5%FkzlpfgBIrvtO_oPGLliF0l(~cr?D*7F`tC#eRzP%eXP@uj~w0 zlL+>&g*OZ&qI8Dl*+xe2SiU!uI`oGOK$vF2Hsrr4A;sww->XxzHIWADP^Z55fiHo& z!iCp$Q7>X{B$$#~V+K+rnC8vfHFb3%S&h8U6JCSxlfW*?Lez2ab~eR|xkS0O7gGm= z#1JwlT@eawX03vU`SRW*>08?k&5u1K-FX^kS)*slKMjW=LFAhE-+n-s? z&Kcus5t$%nrwfam*B&pp>omNks{+^1(=VIxztOuJSoN`4TlvKrXN;FdvuaJJF=n|O zS->@~W1PobLs0TICKGFEmOj9dfUjvq(dD|9`)Hixiw+|#shgIn$n4<~Gy<_u@$3?P zb?+sU?Td<`9b|mYbt&oVT=!X!7f?LfJVYAedd5wwZ!K;a>lYSitc^XIAXCs+K3U~+ z!HnHT!9Mft$G7%3TigXQim#N^_{J|J;PWYka$1&U3R|b}$sq3OoTEs3ng`n~tfwr# zcEwV;U!R}j6-kZWy7Za%yOcu?nsn{@sIYJP)2ik=`*hbz3$6a%`7Taw)2nJ0yQs9g zHibdTxfg4ME>mskgLAbG%V&-8h^dZmERMNHau~S1Mkr0sLcn=p5#~W}n2q1J^M`MT1QY zPTHh*DY{!`GMD;}9!Uy(ErgEAuwqHpQ!8$Qt7RNy_2r07^K&WI>0EbkYDW1p;!{2& z@7m*H5>-ux+&je}kEV^ImGc_?h6*2vHkJmczK^Plc+sM{okTg1!c+VRzJ5MeFO_PB zgIUb9QmJrVuv&gJC7?SCb5f;rsHCRh&9J!kDNv)eL@RC|sImP}&bC_ejEbH$-zaJo z2@a`ilZ!uG((t}V&l&TI$5LM^uTTzA={*czcI>G6<79iqle*XG6EImM6>Zx*h0@Qz zxStr!=kMO5#um@05w2y|qmVAAe$4J$#cXAAKH_`FYPjCF@j;?@?)V?eO+e;=>8};6 zQ4|mu;xnO3?0sJ~#dfT904UJa(9}Nq{<>8ToQh!x5f{cntek~!mgZ~5-uIZr zJg56~AnRnyoX2-mh@8Dz$DbzSv{EGM^9?P8lq15Wge=Mhsl!9=%A3$dYDpf8DUqvS zDLTGSpHaV}X_Q^a^Fm;ute24Xc>K-j)BcB9k7!0?t2)}Do8rdbR4J*A^h>$X*evui z01}zIRJ-Mg-E+8yPu+jD>d;d7o1u}mQGT0=F4+WglUHopNMwq9z)~Tr-i1CW`;C8) zWuCpl!qq-|_QS~A)2_+!Pl9l;`<=NE1o@}km@qAuI^9ck-P;UCi-!V3l9!2Sq;+}s zF8UpZ|K#h?Toca=`ozX*N~Yr7*jm`2V{J?`7HPF#HNmlMX5L0>=;G~CFjsJ))POKr z1pqI2Mu`_2fkEsYXDqVdwo&`=mENs5@7OKv-JizJh?RZYkvC)(uRU1>H#zY*{Vq6` zI9*g7Mwr{BJ=KO3elI%J*3dPZxHfSPubC<`S{hG^e$~a%eqlQZI$zD!zxuzvi(pb>f|>;dwvWfWy>6i=8l+fNVsyuRI(*Nj$fR(WpF(; zx5Hc~?YFLPDY9i}3xRB%jn zY%Z~Dj}X|nnr91TxOjWrSRrsNz__I%Jpv|@YPiz5nsXFO8HrlOrcN6AG4^I&O5t66 zd{0+`d8}R}c3}y$`&@M9uQJ#vPa$`Ct5Z&wvUQ6-8XpRCZ(@i!0pr}M>S_%3>nX3F zM%Q&Q6Ey2B6uYW+12K72|L5Zu9f*+9?=fu46UDN<-{eyuDya6%(7l|_@{c8KY7Ksd z!4U#li$&m4>nH%{_wP^_7b)5b%dIFaBWL>gS^9W6qF70f?=vX;7Zf*77(3q>Mf+<{ zRY|2@#MNhE^M(pnCCZXUi-#^Zq1#GMT`IWNye?6fHIzyDq-WZ#em+VFo!ha=tDxG| zYXlwSbRV(V8)Z$>fSg`O+VAvTtiEGa20jx|wPCz?`SJ;TTKh?Y)2-bxKRNZ`M{4x( zlh$wSX<3;%a;+wx{CgHgCiDj0C9As#Z&hO?EeBq8&y_Kd=-1^0 z#A>bkPaJJTa92-5mshPibC}q%WhrE<7)}{l(MR!iG?b$$d_sRivp=(lUYi`A?v} zGWm_UG@kwVA>RH`UE04u$8d@gR~~GpLc8LmUs*JQBytJV_{-P2>GzR0u$0MB`7y2h z?l41~KItb;ka*3JJ0H5mkyE(4&IgRa0a1Q&G5D~fmotzVSOl5saolqX{_VSIGb=lW znwZ*)QyMDsadz6uyF;r_7{&bn7+~b;@bqVz7^Lx0a0KYIpJby`jQeh?qZ}2Y_%2Ua zqB3DJC0oKfA~ZW=yFvfp$6NvYm`{}$;vD_{h%_(2jAz+WLQH@&*4zhKr$~P8J zn(6k>kOaK))u}Y@)$acRL}v8lm0c6F{BLgJU5cwKc};%9U^`R^iY36$4Y|Ms#8^gC z#iBx)n{MmZtEf|@7L_LtGdx`-Uks*7(^#~@=4D8FH1elP%dW}7b`u+_(L8&wqGRu0 z$mMeFy4fskNI6I<`KbF{GY)+?;C17Kv<8xjh^P66zLtt+^c=n9{PUSMfR}Fb^SK=1 zr=(lzx4%n!fX$1m=r;~l291AjYJoT2*747Cj*zH33b}k|pIgDAfmMBw1xlJ8>Xb7&FbImQ>hA^eo)Es4}H)&>mvy%c`3>%tooP5s%aPr3% zy=BX(c3A@7f+J!TYTsHPZ;XG_V3MQR$&%e{j18V7H^vkpNZF%um^dV*n6%SZ>m53^ zExKmybgp%T5?rZRw1J!wPJf}6nYslwzDUBXcv zM8veyC3WHNbsM}**B77KP3PP*Bb(f0f{>?M#$3|Q5N_e{5C)lwhdUY=tGl-;7(X69 zG)0Lj$o~gCMq!p!h*`zrLz$_vi+|Kg7#QFBd=tUzR%Mah?&bCob?K@B63knb3PQA$ zmnY_u|6c^hc=(#|U>j!_DU!I1S3UvjUW#2Dl0!t>cp90@r6yEC<91$-61`oqqD3=7 ziGGdhDAz>2+STafAnNuM$q-p!qG9nh=+TA(N^rd`Uf8`!1V0q+?6-`yG>^oRA~| zH%&tAtahQqv^ec4--I*(Trv>j%%wc4TV2rDu({K;`828@g8&H(5S9zq#IAbVO1`MB zGNE#u^6lgEo3y&uPP}tQDo#;vmI4-uZ*`?zq6<2vOY@8aUj?nou_|s?Hq!{ySb+S=#SsoBW$xU7!8x%fjsCUs}_WC}PVv)zJKZ)B>tU&79?+@!OUL!j= ze9T+Kf8b*n7GM6%-lg)+yG)>cm0G2Xbc7{U&oV1vdTj2SBk<0~sN`-A=E}Z#ciAlH zR_3xtiaLu%VCtA#Q)~od$AQ(d@Ama~L#9(J*#~DUY!*SJVNZYK#}GW%)h|~<@qo5DVnY`J8f|%%BWuL zxn$|A)vQqt&F9h)`M5)t#McF6H_@N03tc&n0w3VhzWsDkRm6Tbt#tJF$X{&|pEZDY z13zXKADKY}5i|x+lQ?`%-u;a~5o4s||9fJL)zasCOc&>0hC{kdrlR<}#T!sx;;MJE z8d^Cu93-&iR3V>7dtd`>tFDwd55dEP`KTsa;$G~F+9A+x8U;Y%btQr zCk7z+HJ`8HA}@i; zyiH~A;LPX6O{v#LzUlG^|I%{2aY8jO%f`!Yyimd~T!zhZ$fL+lhpGp5&QYvBx8Nw! zxZ)Plzjo`oDio_0#i4MjxML_hSE%ov2i+ODm00MfsRJpWIDgATns7&iH%Qh@c(eSg zi=0e`6@%Mr3$?;~Csm)=;6dYJzZXxJ7O?N-;>pIP6h8WpCXC-Od&%zC7+pYj`R+x1 zMNRX#E^pERG0fh$0#|RI#9s#zh<@m8%WHvS63e7+4*g=YhJGU z4s^au$B{)-PPHFe+8}9h*Ym6|KuOOJ6Zm(jiLX={x%A#rji9a7-pWf35KS44TNcZ7 zeBjjT#yhU>2SuO+1+q)ugV-|{VAM0jc|SmnR`4%@F($li6sa&PiRT;)4_`9!P!gs$ z?hIYMI-`}ht?j2!|N3qA@5&`+tB7>}n=jJLd5821Y3SAAH{y_z#t(ed=g_9s^YV@_ zG!OFX-+g)L5D_EcUqF7I{e)2Cd+|?xp&mIT2Sn1IYf~;+Qv{GiVK$qWXukn5;@I&A zvM6$-xJqE>|BVy_Nr9{0E4%j3q?lJ17rs+$t3DtO#Wis^^sj=tL%oEtH3is|;Xz`z zIN;qDOu9@+Bg}hBCtu1Ue`mU%?1DRahl8$5F4W@Oo;1PoMBi~n2ru{dX59$8NNCsk zfbyWMIss)-acnfhgr5#r$vooe(NG1q>z7g7x4&Xd)T~nwT^58L(Q8WNo|Hyioo~OG z+bKpIXawSe6sXC%*V`MN}9v9djbQ?w*>s8MEN7UccksxaKu*FT3|E=yE_c z7A_~%D9#HXxnPQ6eG7^yj{vl#aI>^p*}@<7s2uzl-}wa`EJn0-WZUkGB&UAwF%#w! z-~nIRtXTPqAXrtj?E7FR@VAZ9jcx5D(RP@gsz?|%Tt!c|QMZJgB9&lzU??+-w1qB7 zeQrl{>RpY_5U46TU|X2H_XgOJ^LaMqlnLTWi~FlleclD;gqh%?E0H|Mw|ksF1ja z`BJgo!@#(eR`#j%lDaBem)_EyLE)DwKcTI4aSjCQ4b(>7=uea@x{%r~Vz#ATAc;FY$s( zgAR&$_oLF&B`aUuk$Fhc;5ufhLG#XNx_YOmErOFm?cmqVeFB{bEZbt<_BA`@U=0Mv zKnx(XDM2UG!7F9cYVpM(+o6+3>YH6rmx^GoUNTkP^17VU3W_|s|x{pPYL z9j0Fc_~p`U)_jH6M=zy4(U2U0$p@I%8k1p+G=>*TaSfOl=aEG&`EJD(i?`?Q5>(7z ztc6+vzu;zwaV}MN1BV|_vdvI`r*XcEl#33@?53*;P8eEO_)THW0)ltqg;tM;87rD& ztW@aC<_@H`j@UyPDr(NGP{%2i`N}GR(V^2$(%PG zWK2li_|&;j`wx^Z#t=?)sBa=wAl%ow(nBnxbQF8Un8oMz3U^jq$mW@Y(~(lt-68sE zfk1WcKJtdS)lI^o(Zkq4UTQf4KXL!HuL0qp;XLrkz;+8 zSRuR>Fx^%-W19Gr7PuXA0%CuJq^JMvg5hzGaOl(k*4?bu2xAI64Xu;yoa`dLi6!Hy zC+bmc?mIW?Lhr}>k?N#a&J2|eL{w;t=_OKrDpZ(8-m3?#t2KAB>m;B{92>f~=^SnO zodjXW>46fxo_-D1T4u8mii zbT6XavJDbG>zen-NR3@#C4D;*P}MKS+VjE&<-+!jCL^T4AfEdaX8X`_rX&z*!#RFY2OLl3U`ubA-D&vtOnjV6 z@Uct;9`w6DC}Af%rcH^^HZ@TSjEP$zSU^(1NV4ViPpwSiFgG2DlXSi6rsb@Yq@4AKjW3@Td~3 z%!~E@eC7U@A_r`iOgMyPf}@23v)U&D_2D=wiSwVP=w{Q@NiWneDcjI{N7G@&xDi;c ztpFtK+bs#Zh$i>IX8;oHQv>Lb@R*Di1akgp2i-5AYpR=uUB3xQXCr#>PS|i z8-j4f-f)Y?-sy;&C1$X&AN*gSVsaN@&6I=pC9La#d_U02D{3I&TGoCg@GUy6Nu5gC zj&cw=JkS~y^D}n*pK@Z3fabe;^Y<6#0a~rr7I>-gzBQ6#snzeE)viubmvJWJ?3IGb z#4D$~gJt}UF2O__F!jgZqUXtCq^PSIHBQOdK7M-$PvoC#1bkaD+W8dm%?lF`-}X-G z>gQ~$XITJj-wxJpFf>C-4s&HCR|*-azpB7Ui|3SD=sDLIFr?!DDfq+w+?W(sgc0xC z6&>}1LXFGNkTHPau7GE5Cv&DHP47G-vg=4aF+Z@ZmV-nz8NU_Ljle(ka{7WvqJnHs{k*KY;)Cjx z*Ja0_>6*mE?!sv?R|id3;|mKaN`%+SyecZ#%})NUU)1Oeqi8^)4o?L+B3VLAMTNF` zoIVo!){*F53Mz$oQ4Ax5O7DbfTkB67hty_A>>`5bnXIEzl-%Y6p1m6Ar>_vRNq+;+ zGxAWT>R6{Id5P`!8q~}1p>(Sp40{|&aPqbF%=avhZd|Y3FQZxDAVV?2A?KA_?|LWo z)!aaaA2Kw=E(-n{oNE4UKQtBJpxE$RKHz$!jWwm;(;AOQmMa@C4VwY(z9mU}qUZ11 z?xg%JlS_Epcu2vfYsHgJRB#3xQ=z@z zY#&(6myton+k}AFajT=QU!Ynmm{OjM$nl*738BBdu}k(7{a(al1f zX`;ZPIWWU-mHL3~hk9tCr%O4K^@{{lb%k)8D?&J6i%tB+jOTSKZLi3SX1zUK9zLd; zM(_^8LOcLh9S)gXqK|y#V}RuFFPv+fd&NLN*+p}YxpXVUPpxp%S?Htm^N{_VB!_#8 z{zqKf15!VNdP1Dy4yX)9QL}slvYL0$-(6}tag!y^9%Ij%1uO9-RoC|V^!=aa4;=%F zn5ladD35bJf3HZ45YDZ}zb6SbjjSPa0)DtUwUvkEV{gjc9_(2Iyt}zS?4bharCj$0 z+X+B5;wVMCJ?ptx{CU`#6q0T2a)Y_{g9v0m_`3&3#F)!Mv{eGWd!2Fb46@@owf?$g zARs`$MZ&po{1;_+0u;_gbR~A>^L4`yed6%@<3}&b#VT}eQb{O6Gvkm2tJuYpCz?M( zAwP)m9I3*~zJQ+0a;UW2Sw3yJT;lY|e}jAodyeCQ7|r@F{OLDlwm?AQPz`g&mt&9X zQDt&O)TAE#j$#C!U=;pBIGoCh2*Vz>4BWZzT`kb`P87CHtT)Criw&+BK<-53b^bTl z7H070X`T}2w*1NVSPCcbgs1>g>2KJLZvuCL+5g4clEF;KNhgE|AT!AA4?aWmX^FmZ zQJN$ZReR%7VS%?_-)8OPwyM!`vYhH)FX5lS7h&G&py%XGG^z8aD_}3j`FcR4(V4~L zwaQsP*erV4fRnwyT2ytI&QKu*czA=ypGUK0e+Ne4jLu^f?qTXMFo~770v53yts9!h zikpGpTA|p&aPeX}fSWH7qYQFn>>qCT@wwvE&qfjtm$F=Mf!>7GA1DQ2mY*^DbpOv+ z_@9X-1@7Q4U1K)x`KJyustu`W|9jkZXbITcaxJ!2ijuCaG|Bn=>1O@&NBsx6HMZ=1Azi6|J~>Puh~S15;B#cfU!5V zJ?xuj)laHe%f``tVO6%1ko{LrkRn7SJfM`BIN~w*L@#PK!uVeo4 zuSPG;$@>^4S7*Q<3oi1zbo)N%Fj{oZbNx3^L(nrOkm7>P@ zZ(>YgG@P%`&yHPjCOu5?$4iN4>oq-WEIz?p=eW`fyiF#BhUWNyee)-{F30L0@6I$O z%;liCKD2P?kL36s@AH#K*!>%>QYH~ZpI>zt=SCFKw_j=jO`w~}nT!5Kvcp|~u=x2=ep84-s-&tKSjNUnbHLbl z?qYQ>N0{~*0GHgldOSW<3#yc+Y7>U;Z(2&*DL(-;#Q!&d#mFD;x1K*c#U_OgwgBXf zD>%bEDP-~S$X@z`^$iYnPIJ1UAkKw#TUbJ~rF9!d=q{7^mMlf^#oeJ~(13%qF7In}M!sbmb z>+0s;6F-58QHWw}b?;I0x(yS?Xkj**{p0lU^gaXTeDrCHr2LP^gyQ`XfW}$^=stq- zD+LkEG=t=H%(LA?!M}VCk1zChw3D*uo&t2y&}6!K^P1`5e9+FbhZFpK$dXwcz2TPN zt2&dA!(=t8zPmt2FwLER(8X5^LVIiX8-Eg+tpC9wx(o0=UPIHr(eDXoA>uuB(D^~^(biFuqwp`o@nK*_T*@+ z{KfSCO7*UKJD#UD4=o?=e2`}Ro-O{BgI{@Rl@Im|$EV4)&rkwTq@BfLhSx%=U>&T> zrtnO=r!9f7kEgF1*2={iEI)E`dxA;B8*4A0*$ZBNYM6e#6lF(i*hw!VPc3}Cx-|q1 zraBnCH@Qw*D)`f1$ft2n?t9m8OImV99KI>{CymHr8MFhDezCbIuNk!Z?yJUxypu)q zlNL|nU=dpWg19UG1DyT6Rvx-x9X@f2X@YPM;d1r~_gRcj(@Z&?P{L#x7 z3&7h{^cdKF^Q)20In+&lF~uEdC77;Qt)|08uE|*rWKSAl>$t9RF(iE`?fVw%jI3f( z)N6pnk#7W(GI9EiWMPETJeIWGQ$Eji)U)f_)zjUd{zHMbXOy#IezUL0oY07qZ#_y+ zY(Q~$C~@MRc8iC|CV1~lv&?)Sj(l_N!hAXzN+KX1$H||OTiUA*4IaXSxZS~^n3!xc zcwi?unEjL$tuv=tP{d3QAjNppbTh2`+a7j{hNJ|qsIi;8Hq+`q1X7utK#m41hD8EL zV?pw+As6E9P8>DI6KPv7PiD`0FW^po=n+kpMTfmJKUw?;^l0g&H<; znPOh%_SfF^f0i|2F>UFbM9n{lMSGvvg&h9G8QNU*1S!Qpq~AA;d(Y|q%uFZ#D3Rc5 zGU&^}r32QN+--NVfBwN3qI-CvLa-8K%;1aH8UL1gB)HtZhqXPcQMms!n)uNS1J zh69ls767%P47HaRO{r?Ly*qQgLoY{?oAY+W1*w%qmrTA9i5?9`8%d0jXeaIJn-YdD zPBIrL7x837NykH51!|1_6A)9HUwxG0^#I4!0*E<`kP62G>?<1WyMP)L7%-%K5)KJZ zW=CI+v)WKu3&)FmeSv*4zb->+vA}KIJO`HShu{?Rd8~$ND)h8~T|zd!8j4cSrLE z)stl&<^BYDL!wvWyu{-q=j9Dcc1m|zUn0Y1y8rb%A~P4F&jf&~2bLMa)$rt~xhL1$ ztKQ#2<7gPhueT{Sg=u5xpi)rE6~CA^!odgh=qAU@@&tWELOgzP?K!mf4`!oXC0sua z4jnSSt0L$G>ciq>*e43;wWAVQa@Aq!7Hq;)JjEuygPQH*dqdPJ>Ulo6>N6L~U|X`u z&m)9hRmH?xr`?r_ z-s8G`t?RkCs6@p=*ktHN-KT5KKguc6^WQZs3j61bHXbhYk%C=*Y5@B6$lq=PBx40m z*T=+fl-*!(3R;y#D)l3HIC>NOI@{uJ-XwuAuAA%PErbY>^2;V3r6qFZ`8SBlA1u7|M-W-?)-#X#%B$d`qIm-7N;hx*B9j0- z(nSBg*0a58#Xpl&HUPB5CXa!wmLr_guU~F4{rdLKF1zi_7E~?pnqpCc^MElrgM}xV zm?_DKWPB{?WHf5{9lbwaZs^&ePqil$x*EapB-u`3{uvIA^t7v15{tb27naKO!qyo{ z&6OvswFgHwcDJ%zYH{OIugw-v_b(Ki$Ba=3y;fG5ZaCQ~Y5R4#e-`OGdXi^i4`R3`Q~OG~wCB<8@8f zuVJ|&5t|kuMS0t20#EzBF-P9Tw`5H$n|(6KM@!9HyM2Dtx&6fkfy%a=8S}nIw*2GS zOhXoRYJRuYRbge_8)p0zO`^7XcDImmalxGdP2X&XirC>*haz}| z`>9nAi>Tvw8csA1zLttQS~mbLnDcIEdOpy(a_3Etb(5L%!?p z2f+kdUG+G;wfkuoEA-g-yhMz2F%PSnC3(o};%(YI&0{bD-EvT93m4EnPuId+O4WMp9Hy zxh^hHfJztF9&(gX%eul!T*Sgd!gD2NZk4E$6{gAkzVqi25m^;tey9lzU#X(xNx*v9-h)3pj(5JkvKxsm;~pEqAu}Y%V0+>;!apQ z`Ox*>-yhk1XP&6BlU*tNIZH;GB$N6%Eu+d@*72Ug+KZxXEn`0?;H=kHKN?n~JciGZ0_aHk)Zd{aM!R_Ge`Agy=#`yw$W5L?kv?mp{t)bVF%RPxtX%>Wvd)?{2&Of}zezp~@m%yqZKjH$;82yOUr#e?fwYe0) zn7HemU%q%kv}lUNyds7Y7MNUjKF_Ra&{X13Cqmo8=;=b^o%CRJXu6WI{ws-F=2 z>DVBo=R`GAcVG#3#_=f0FzYY6)`Q19Ut=nY6r9MoVIrEg8XWSKGkiF@MvCp4jod(*?cdPL*!jV=4l>xD1GQ;`<9Y|p%jvC#JHP04l9e_Nmx!ZHkKH}r!`f5==y#1KZMIowm(Q?22iDz<_>y~G{f~m=jrfv#D*RKgHvg1ne&Q_b~(JCEqR(D;H2XWX6G z^skk#yY5A+SIfePx9)}&IDdL_;}GEcj=%y@N#?XLj;T%F`0?!{>Oq5eZ;A$rY=SoC zOFkS!Vc#re7Dd2;egBALv08u=zSmK*b&0beu9*3qcV|%?S}K0XDX4vi zu0twOgYEj<4&B}*uPdaN?kVRfPI%vYY_HW=x01F%MOh4+jYB`+hDO;jWikMmY>>+l zNKM*{2nbi60%BpS?GTD0XGuRsns9XzPN79SLWWfpuF-?%s5bgZ>FGUiWHvZ!@f&XM zrFMW{uIX*-*@12&KC1i;3Id7pgXmnQ#ePL!h*V91$C#yTBAwBh@-pQ!!-Iq& zGR<$xBsRSBg9QJ^rgHwxg;)A+W3mrbASMBI1G^N^+Z;MvyP|a|%?0Q{RF8ds(i-EC;X_rUqde%F%+60^cWhzHM>O1kK2jKD64DkW zr+bX(x#Y7uv91jn&5f#oY{<_uy|rP!T0$W-`e6N}l)W@~PM*PESTp}|738_+h-!k0 z+iuSJ1@iU3j$HoUSpWwKm=wXMaA?<>9f}ANC5Iw3-g+258fWSSJA%@i!sCq`XX9)q z$!r2Q-oCFDjmoE5PSM9(y==NQz(PoV)l-ejNhVY6)}wdVI3Z12 z3M%jMxKS`~3RTL7L63f2m=H&8*MY;e7Cd0tByYU~tcV%PPKy4M#*VmeA;K}MOQwPnMZ|Hz$T(vij5Hi+U8Y2pr)w|n?ph|3o%9D%-kx%ugem;U+`2@9H4K`g zOKW=MZ)hS*o63g=<-=HP-%_WjQhBJcve^*Br#<{TB{4-`ti#0B&k$9C625H+g^c46 z;cdxEaNYsOx*~w^FqyQvnWq>V?e0mY;(N^1kH>RaqR>i7CS1Y~VW!&$M^T#`X8&^c zVDEuFYe^DcSS*O3BDXrYC(PS~NE8zlf{rk4a-=i$vL_d%&~v`<%=R1z?ClLb^Ct*+ z_As~Qn}XNKxlx%uhX!^!U2B@oB;kt~#ivq5`@AkGf9*J;_Jb!&|8dDznSbs*dGTQ;B>LaP%OlJrW6)80SK zh(wl{(t?P<0p>6Wwo*Q-iQ^kKTiDO1yk}We%f6qAsbJoT`_63kPN7Fm+Nn~HoY{s> z1R1!A#(kHVrgs=lrRoD!=jWu&$aGmXlB&0P?+;w;Rq8Lys=FgpE3p>wZ{F*K; z#Xj~CAe0!cX>oE8As<7b1ggpq2>zeG&}SrYGK8pxMbdsYiv)Esc=40?6>(@`W9~gW zhBpqHN?9Qtip8l#D(|#zbx@EF4MoxQUZ_leV;TVeXzZ8$gQ-*DEYp}OvfTee`#J%J zv{yvCKcltVrKeny%oRyY>0X%!h#CDRG1k15chuk8p~J^I%vhTVM$oqQ$84zTbQ<~$ zxQ%CgPA=_8btYyQ%&5y7m@4n*@_cPscP7vK*)HC`BAm1;N)49+vph-m4z z^$5DH2N9pchZQT-|F}ZX$wDjm+%5iv77Rn9jSnxmKAH}7CefuoX{w;10+LC1r*LqL zCk)$MFept;(zR}Wd~{D@qY2c}+YA+bVwwICjx!)t%~oNVrG`I*&D2YwY@(5dwNaV|LI z^!?mZft0Vs!`L0P_rg_Jos={`BuGu%Mnnm42VUDhXj={~&2@^o5+Pzm@a9Ts>|O@{ zsDM-OgfWi>q*lhHr&h8t{gK`PoutV|O62@Xv*iz)hya>+K7;Os8y#pj>L9!889g#r zgHP_NLblnaOo;BFUEjXqYVYr5v)LXe1)Pwq8W;~N-TAA`92N2pv0mH*5S8gS_ERKu z(nM6;&ZQXi``!0>>+yqxj1DG9_N93#wrk&)-%AlVB-Y$4H|XtNKHj>)zJ$%`Lvk4M zUh}tiHipbx=zDu54~jHbm|T*I_Wa_zE4rldCJxX1;?wVCSIdG%q|27t$7~U|DjQGB z5+rnr+*oUZR^k8pX@!J2a3Ovp3vguq*UyMFJyG7aM&#g#!jYkjr)r6!-Hr-WbbIZ1 z!HHF*goaYq!(A+=OYr>`#(t8DiKLPM^M-)nmfw~37sO~y9{Wj3iEuN1z1T7VgO1m= zEYNw!FY7d5ugn6vQ(iie zIxnR$%)Onen}^`#3Yc$P@*|v`0I~6`3J&*dwGeCtb<3HwXjcbcF+hyr9>^4~YPVzE%)KM_p0j6YfAmtTZFc8{@}CI4KYirXL%Y z6M!=E68ouaJ!b>PdmmKpZ4ZO{oH2O|k6GRJez*#Omr$@S2*4&1Z!OHcPci08rh>j= zwx)>a&y*%LOnkhc9D0#L^Qv~J_DS>nYu}Ll@4&d8h5+ScTbl&?UW(L{2FJWdYZx^d zcP+5W{P1pup6;pD)$KtWoBRyQ7Rvw50w}tn4-pr2se8HWKc>0e^o)fDA*ICR!1?vo zfkN@eRXtojK)6(xXUmIs5VrIe6&{gm@5XCnnvCBIP?xeV_tMAr`<9l?@V z5m9PU@Z+k53)LQiz?#XC6GZ4`&prj0tr1sGF878y+-<^Q?7Qk(U>(qn9GZ%KFiRk_ zQo>S>ItOiS@@1cIic{UmyV+{^<5hfsZa$gY_}bGwt!ay)Z{(wpRPnn$-;dhFRgKal zW8dicFZ*|WjTNX(n6XWHNHN4470=3v_>d*R*Y!-?4Cqn;&ol>ekYYG@%UbE;C=%+t zK#TG9_`neFHRIzvd5~2;6ATSsIbQkH?6=LCCx;77IG&{Y2+Dbd*^8SQ*!tSf65K<%^SQSLHTQ)x3A;0*u!)if-_QMe@UG|8hJ{Gt_14ILwNrp?V_2HN$pF$}s%UkG z1NPOEA$mdISuI4XNi9vi;w1D38`wOh)%<* z+?`(jvRqo!R{A>q`wuLwieNwbMW5AiVC^b z-HZ-SY3AP1WSHyf_!j5coeWk2g?{$ndH>vq;-6}#zZ6oGEMYa5iJnh=2O(PoA-IqjqwQ`% zwhPv%eHiO|uZndn`3?xM5F&EOftvl`24Gra(W(VCXYMmE(70lTX^Pz|n)=9OZ%{*# z(ob0*a+%#3pFXvBwJe|Q%%pmT=fmfnGiF_z?eIq|+=8$Bp(Rdq?cZYBcl!)Ow%fod zUWa=O%I?fhM7ROQq;wyzlRUo1jU5tnFdlTmQ?+37n1u1LrSqxf`xZa}fvtRuiljzh+optC)#z@Zio57B7tsix7@IK5r%4O>-;YPpqT(I# zb~k3l_7|4G;s6Pn&(N&Z0X4%dh+kWi*Ttn=X&@$FqD}V}LlV0W;=I*#k1kPvHTcx( zddFRH+ik(^<8>!Muk8dBqX+$-qJ$|mD^=wG zpkV%`FEr=SuD9uo?9bIbe^IntVv|?nkm!KN{cOopX|$&;p^IxqLY!FoO8khPMy9 z-Y3cV<@p)E^&gE(C?D^G@)`KRhh%pLry_-Z-JMHw=3F-qrbbxEMF9s+nYTC6Gk~>P zX@P=}(d6yKR9ZF%<7?t;Lg?(w^_LsOb6W->(!!*{>CL={I}V(oD!Y#>m1Wh!?B4D@ z4$bqSq`d_#*l?65D5l;N@l;Szh7b6wIO$1D;By-~CP(n3i_b+r%?vwlfQH{DbmML# z;$~z^KjI-$*?oSE%iP*q!y6<)7^r4$tI4Wzl$+Ar;MG8cGa}@iP?NWEJi{Fzv@8Vp z)U{jFZTp+e)DM^Oa+ght)q>{KAPAp42B)ZdW~4$roeV5aLnRuIE?FTP&$|nrwmd zKD7u@cv;_n+hge7rKN+mGKunKj|lQrA|3xDtQFi}70P4QD-4;2dInb9o4tBIczomJ z!$hf0oy~x}_ko=4m0O)FA3I380*4`tcg}W@1yURMeahKW0MQjrs=ijIW8;;@(DJJW zFdGjlpCvR;THI_2j;2pwOs}d1(CGA=96yfN>W6B6dxHH!(?}#ol!>e_P&dm<_ z)s}&O0qtGXy4c7OOc(xhQe0HkuI*cRH>0LPqK<9|6~%1wLkvaUz-_(ca&)tZY){Y3 z9;hGkKIg4f^hLkT+CAj=GSP(eCn(N6QsJ|=QYu9|IVJYZ5f*z+(wDilV$okA2Uyh{k z#^;f0NL1Tn?=ot!%ZlFJSjW034y5Y)kNv9o>{D|^_cTQS%HZ|0r>!%;FR3x*S##Xy z@I@a0U70{6!h3rI&KInmbAR~l&lE4Tr+tMKo!PJxy#QSdTQX$OoeHDbYi+Y|FrUQk z;R7bJq*0#ICRhh}ja2LrNi5$Nk^e*8TSi6QzH9#q2!qrhH3-rr9ZKlXAfS{eCEXwl zNC`+c$WYQEp|pVX&>$TmWe|gO_W+Ut|7-63+k4-?XYG5h|9aj%FL5ngUuLfB`d;UG z9>?d9C03!8bvd1d!FJ3o!b6XGuwt^3#O0e|5^z3bup2WT=3+XX0D%`mo=Zk1 zJDsEI=nY|&d&9BC-~|(VAB)<{V&yya5Utc03{cd&{I$Dh#el=da9Vwhh&m)I6WV!g zpLjtx4ehF}#Ck{A`2Bf~lrABH>YPh1y^T8Ac=vl?MN$iwP2z^!m0h%+enXrMa@=-o zbCvK0^4hN8N*9H(`J2+veFbgg*n@!2FHC;uB$%+eQ!wI1eMHDi9w=Hr<%|Y-zDN6N zld6}7Sqr#(5)>g)#7@%6*L5{~eupr%(~(=QjphYD{d`wIG(U<^)@XDyr)i<4_w+k= z&~-HZH&UkRYcIEFs@9txfdMqyDeH^aX9J@db5OKu_T^n|ySb<2=%DUxKCP;Sc+Njv zx#5RQy!lApvHTid2MeB;7OOFltHslqmVEf^v2(mh^>Cg*W$3kfzlqqba7D=844D?0 z*N2%92V#}p&Q?x__&*<*+hw@h6F9UKAs^4wEOyv4uHVc1R`;K>0*zAJ|FMSyR%@K1X$iP~Y3XIW z&YtSA4G$0F#td~$2V$iiv$qMZF>e<~y1wh8%lsUt?S;CfGvR%!J9b0(in)bLfPvqPfn$Y!nC>u(hZp1Mpe(Vn@DKVg)i zfi7T>uW;dVoio3aHO=1CB^PjY zj-GUPI_~%K!Gyshd=hCWK>icyag6aP6CQaMr*3yAl@71Pxcqzrls~3Vnm8TBZu`8( z@6TIzfF!ylxX3y^Yv=5{{v_)rl*d9YZbtZ8j%kd8T)HabN+=39SnAC_Ub}1Dkpn#V z{T^1v=E-5@a1;_^1`PtbuYw?H0V_n@5Saxp@t|>&&^?>#!(0OIWYh}KFgd@ZH=S!T zOSAVBpY>kuJi4vc&6cS^nL<9gXK65FquO)KbhMr0nX#3Q98O}n@`^q=`%MA`99yde83J6mziWJickKg5_L z;B>dsU*ZYh?QHXGi*2UsF&nt_`E!rvpU%~CC=873SV2IdjH=)gH-nT*COMOJ=OQuW zYW@pz`#>O9;ehoEK~p!*KyX%h9Y{0?`^}dxX^gUL^F^6K=0dk5w1SR8fFtl_JD-M@ z%t`df)<~?Mn_el>g+||VN0etu#4ddq=)eBVg9HM8&?EV1dZ&$P=%{IeI7J{mnUV2B4@mOXu;^CAWb* zQJPG~jcQbzsIrj|1qdXDfJm>gz&luM>d7kpO0|fOlK|>J?i(?a_2$v z4#}MLd@DA~*WpBYB6vXAAcO{G17yoxU<`S4^4X_3q0Y%qQ{LdFfhnO46AxDiQo1|Z zX-|!4QgN92%_iLIjy3nIjdQ<#c}ok|*{dCjzA3v`*)YWIPW%bD`$0)f1#PtzKz7d8 z+O;ioJyyDPcl;))g=ol?gvYFS@d7B~rS#ojK3@DH?U;A8_7Y0A3A;^?*+Io<1ng7{ znP~&|^gFiboW1j}PndrvJ6VP-ufZ6fnu$I;zc6hIRoLk9u86-`W_+*% z!FN>NjteSG=mF7W+097oTi_ssX%t~FU7A$@@W7Vu%yMPsf6q6+d>p$P?9iw?Sq^%w zT)d8^P4Vj+XlM}3Uj8FCqFMgTGS{>`Pe6Dm5qgo#t3O|b{!-7l5JdHxYvt?rR>ql| zWvob(qAF9Xkg6$aBZTlkYET6Wju*q8AcGRqS{7*V;|h38F;X-4QY13hYf{_ga6R#) z4Z#bPq0G6&CEJ#D$rF$pm%2$N0zuGZCPISU)}oqvV%!J(V5V0aUrq!TshV0P;Z4x!U6i-!ynR682asl}P`7`#Cu6nh01%WwaFRKY3(al7}n& z`;g$jFZ16*KaT@g?NhH{Bid{(%OSuVB$QnqNPAMg_Z8_v;W&M^02)rDofJ=kx{rG1 z#2)xjrn|F=HtZ4?uj@;sc0h4SyJ4-TWe8w}Ux(qiK5V|XlS-7J#c*0)gs9h)sah|8 zDEPut-bMSk^2}P5o5LYR7=PumCOIVm-T%jyWqXB%bN z^)&qcrYxx)!bI#B$LMQdJ9JZYx>(VHxLUA?)jjTIyK+d8pfCAvEoY4%cSX4MU%{RV zJ$t3P)+1EXJs&BFX+Ew!s>G|0km?79c#8&&1n+5>ty#0p-A^Od=8MSvcY92UdvzUW za-M>1Dhs zbmuE>*Hv|8i*Y~E{i7Eq9x-&}4sJfhO@R6}oe;sYNF@OThryUXGp!frj5n?=P*C(U z>^!8iY_BP;ou2d~jX_KX*L`K>>$D)c{nmM25O0scGBi=fGJtov%iDSGl_9C^P8(d& zWfyZt;wUrHGvsC1VRDGD{Cv#zG1A{jv z_FwvV=D*x;Er!U307?`Do$_6cLPP1G>oC)la_F*udZgR2zC!7blpmVEaRu$6JVfUC z8MnPO--0TtY;#owK}{;G_uNc<>?EtJEa!h1ef_nn^Kk*C0h5iK??UJ?{7)LC`IKr%IGf&~=xJaLc5 zmz71iSoHN_JZ^lyJ}D7P2?ZiOI-t?*!qmhmbRc_BrigaN=Kejn`79z&z|6hulGF;* z9l}J}#-+d<(bexbZ|2Tt@LVQwLms>@vR;0xT*-1CGOz^F!y(ZW*G4`&!b>Ff(PL91=6T;A4JIcb55&=;qF#wz$)b|FEJ@3!9UL zb_UBb2)LFk#x4N~A;}EghZMp&FwUr){**wI=YUW|-w-&au49(-#-tejzpy>f|@{fB-+M?eXpBb%F;FE_9gO^&) z>u>*YM_kA5h~I=Te3z;9oKEX6u$__oA-8S`{@vhgV755t=2AkiTg~hs-`tYtcAV0e zRDb1glq_6#m?4UZ7k%K5;@|I{G<@bj7R+)}ceTV*VWRK)Y*D`xze)XuXOHKfW^|01 zV8<$MTFeoBejsN`O2b&e$ITyKHMr@+m~?6r#(*X>r*5sV0;?uFnrVc_dV<-M-$ zDS1f4dUMD;x4TZEvCrhzd&F$|=(h01Y`V(;AzJ)K=8CNnNJ^bq3{pqCYs3Xuz8<0S zVPZA}R)?BXb)c~z$6*jA-tlLiXSf6cO5%$|)+V;(K257s^)ujG5xHmRIA+NYMbH?}bA=`b-_7xpA#+1?sJ%c>?B` zc6>SqXg}0o;kVkrLtoYw!gJoOJBwW1)PJ-Im%=ZIe7H8CaN5{mPUkXy=0fg_$91tb zcc&BTMSWpgklbuDh^yxds~A4=`xdGOh|TGaEl8!-rs)t-((1y zp#gx323c$Q`&yLY!E1oB?YafkUPs;TctR9(M2>DL)ebv|gTh#FjQY=@Re6PALIr+c z!6P9zROI`||9_bFDtXX7`9jFQJ%AR(jam5}$X}=UU}>L>RpCEGC8oud`#}QVLfmfe z+)dCF1Rux)V(`lkHiNnD|DT$P|Go}UK%vaGIP|B?e}9Bw8XS=F=1~F8xzJT=UOmkz z_c5cI98tMp$)l+!Q*PhXuoqx$UZaNU!~N@Ac=4l@-s(xr?d` zxcGi*huvD)0*sS;?zK|Iii)oCy9L%y5uBFLhL}fdMX>nt;#TT+B=W;^j-S^uPVd|NH=M z3Nj(tF;~`||F521@F64udgq~uyx0Hs0sRku^uPX$TjWsYLe_v+hX2dg_0P|ayn)?k zqhp%J|F%;9|NbDOgr@*mhh#JSQ^oxc^W1+u`~Tx#yJ-obEY@cl1?+8&$6%TNAkUvT zIR=)|EOX$W;`KXfoT0h|rct`!&--c>J8!KQR1bG$(vJ**Dk~}+zR29=5h6~I008xlSTT`CbO4fyllXpn6|q}LTG#o9Nf(2fuH1+Gw?If z6ufhJSll$~$KcY-d=2ph>nffHrU3`4ScbRy5Ksj+9Mp>tvskAM=;&y zlR971&}f+M30+gJiuD<)7Jty}_>rEn3)oN%nt+RuOmV|#=anvIMf%dqGAgZ`AVK=; z<)5KTyjwUP)0h~K5Q))6&8oA9j27P}qGykr&&wy9Yr)mM3)_+*40w;WFm?rnqgvf3 z#)YWQ4z~#q(-gPHfcKVO>H^yrzy{o298-s6PM1Xa)kS?mZB-$%HVF9@S^xVS42+k> zwj{Cn4;6#=HYl-;4QCMV>vUxg`$^7Y6WN_X3xw)j3Smeju$PqSfp@NaIq>3Xbb&AN zccoJI`gB$1?-FzHSqWqUBQG`ao*F0}T00gsDwB#1>H2kJdJ zeOyk+4Y6rY3rriCU->cUCf&Jx(F0!=>H-uD@)usfuy_;Mj~9g3UIkDVvVu=%00tu= z6X6}`W{i2QOS!-Nm1q9WCCERe#Cj5f7Fj4&N3QKvLd(QkjpHtOO`w7zB?qeOgT}PsgtP4iV+##8xFy*iR&Q z$GpxM`)a_33woD~ZD1ko6@c~`D!v0ekMmgHyzm7!)U68*?9D-Yrb}0r=71GXlis7)7~Dk2(w>>|JEi*?f)ola?h1}#N* z>Fq>PfDHJn-?pCoiR5Y^uyz;C3M2y`@h5R$4xj(h{3kQ!j(6X2gwkl9qk2zq5j~~Z zREIc~SyPkS+UVLbWzFdKV^d*O6R1N^Pfl0gvq>(;yqO66*V6Y*;k$Y7{NNkWh4t$= z4{Et5DOgDTd+Wx(7C-e_Cy4acFR31VXcoxBhRHhLwCMs8uwRV`^TI3KE`NI+A+O+A z$OEzEQ$X8Rh4Js>f@xYlmrrI#Q)D9#Dn?XsOusMss zaoLtnEvM^*wW*)6QG^tqx}>q4lk6)y_IW?CxP+ic-83I?IFeY#iW7&FV;g`cG1Q0H z6ndtR@g4pi{-ppnbYe6AiR4B}6*pt}i%>b1WMB+!9S89+3txg~pol^X-!~oj@s}%j zUAtIwN!!s^a(2;a&xLs)*1Z`LweY@V8ziLHP@ZF|at_XjOpfK41a+1owby5W6COth z4>zt4Ybc!mwc}TNPETJX4_$|P|8(?QWM#a~aK5U99@>l-W#2|K(X}$370%5KWFN9TE1BCPjZ`a#|=^HP32|o+=Zq z8eSOC&l{(yLj)9Jy{8s%u7J0+wFFG(ll3DB+x<+DH0S}?w(PvzflR@q&U&MY(o>4M zM7QC!*-@b%FVAN#GpcvoR9y97+{S-j1~@ntDi8pUNPi71!D4~zL(fH;JYg!K2w%Zu zbIZGCK~1J@L4*oSjba9bvaR4ikHQ`)fnGzVHy1=0{GqTD&&WXxPdS zn48ba5?G+(ZAd|XcOK9;$jpvcqMg*f9fV}>_^jLy7C$HCAwDyl3%su%p>`c47t_U9*`x6z(Mw*ktoJx_3aj|2-NEpJGwv$L4q4HHwr7@z@YAQ9icV1XyW4UuU3CR&x(v%g~wX@_i~Rg)1AS%hY~*U*2w zq<uKxx3Q3bC7b!cW}Ykr z_Z7Z|-l0u6zRp0_5Tmvr?%Jvd>1P&sHdgTA7aHt$qNV{Bu-JYcW%W-DB)e!HNrKrS zzpwGkyhkP9&(&-RchrlX; zgQDE=-SO@~Dd7feOSVDUb?!VUF{k^4{l{P?ho1I_Vj+Y$#ea|>!h%j{F7MIXOlUPY zW=Zyh2Pk&Z7TSpf-kBC2b^F7aW$A_A6U*hAdY9{s_Xq5n5?fb@#fkg;n(<*J);0Mv zPs1sR^FGQcx-)?*!UQS{-RpQL*ZPAYmmi`DLGR9etSNQ_7m+W(Wt(Il>#ZIq;)8^fy8-Ej*iJD3(Msaw2PX>M zvWsEZ`{nvQ&+(;3vo~>71^sb-4vKigI4u7j$aBoUiP<}F7~~+6T@qJ2`c-6mw0?sv zpN=R`+7J(%F@iwhdNacoYr1sNh zyF!lw%FT=ec7?CWcEVT6!$lruG~f^tiIphB-xtv-eR}~V>~&JbZ~GQO%Z9PUBkU*o zl>hY${_kr7;vLDAF2hO_7Nd1w(nVs7%Mzg`PC#RySpq0AxPN)SfnqF}vy@-ckBQmk zpmN6V?3#eQAukfKx^phA7Hvxpr>3NaRIxhA!5W$g~ioOrSw_J0NfoU>(8Uz!=BGELoX{#v)PT{?qdPtnP)G zcXq*zlf6*_Ep?^__1uBHb$uM^A&6V!|FV6{Od+j6)(fdInu%$Y;5|KS zt7rGwu=kOVOiucB!}uC8&-*=cqL**I1bwF~ zv7C5wkqJ!|TJJ8{IKShb!cc^lot#qfL_d#Tp#f=7K^cv`UUqnfRS+))pt8Dzt%8n> zM)S&6G5`qZaCm564Mw&U;ul{r33f8NbcA{s-x{sRFuw3bZ77TLGsM|c{#K?&EyCN> z`8&I^-Z6iVC?YOz>`-#ewP=i2Q(;B+GUVdT=_8X~c%%^C7Sk5XIa!d0^$ykeUR%6N zZ__n1S(>dvnq`(%v5~7IVectS&p5iN0ctANXP9sW(PK=(Vt10GCNX=y8l1-YfwMp( zyUf&B6|KcSAT&Z#Wp&Fv?t*Aw?aL~Kg;_lQ423E!WA?_g1~l4A9>18Dq0*&(sdG zj1D>w?ls@Z)&<+?yVto}o0UxbdK!9Bb~jS~KAv2N`CP(`Cv;NIm!UZ<=yjWdJcuJF zc;qYmb{GC*c!p_RRP?Z4UP2d)u6yg%x?XZVC+{6hdHlnI!Mn&tRE>`6F(%AEs#qnn zsfXM!Vxj2yc#yDXnpHC4L!t;=4lZ?2O~r<0VHTsJxbh~7X=R;X_A-Zu?3-Qi@BqD_ zWMaN&r%1NlO2k809R=idB_VEQKI;qTmHVB?2y zD*r;^Sg|egvMtp_Zus4=6Kh^m(_i8+Q!;u%jtEP7YW$^%DJ^hJI)$a>;clvYXPy-t;iT35r|wxfB-{jlU$n52%|HJJooECJ)iWnFFX$x zBwUw?8A4Md=`f;NKWVhPQZ`(@)I+kl4_?m-x2SmsykR6u{{C1Xk>}JFS)#0c-{E|_ z5WqOX_-ts$y5ziN&N_o4607aV8c?!DK8Z%&XXqJ*USK;Tnx;&GS)V#834r?Voz;kQZxDVx2FG$-9%r?G^v-Z&9ACo8zf%othnI5E=i zrk~1j^fuX}+apnexNP8?koqgKhC~bEKGQ*u=-OxwCq#EXe9R4QSa8m4%yxI|@+jlw zB|6a~YleuwjtS}uOtlf>tdjB=Rv&Tc!EFL<2yQzB16D5CgE4 z@npkft|HB2e$+bHJh-PScy>ejv1QG;0jMpZcOMcF??CzqMDA7a={5y%;kjIDDwIDP zhDe00)SE@=hU8H)saE#Vwc{~bhz7aDP`&cpkZIcZ`NB;3vcF%J(WCC{Ef^&`Li3eeU;5N&WN;}9! z0OM6AyAl7JPy#2xX~t7mCu6?_#-@2E=lHb>Z~iPByqk#f1Jv46x4)baPhO{}d!FzL z960ksgOdE-dAvtt{~3Ro?M(0P?=1%%iX+Xv-efp;tsN;DhmK4?Qw%+hiiia-t6f{vRg(Y=06-18}dLXF)A4K$Wa;{0ud$@+= zZDQnWq7q8r>R*uOf`e(dt(zboUE^rsZ7Lfuimw-|FLnVODeQSXs@5~gsQ z^MEA*3db~re~lGY)2?rIFR^_@Fw~^zX!FHA#vZoS6kPJ zs&^_GWun|>NGo7iQB9kUTDu&M9EZJyYsN+wQc^tU(&gn?1zLflIx<#)ME6NmxW$Bc zgTPQ{%dY~`$KCbTzvB<;yM2P~F@N|6of4#K)f$!j8mbB4PUZ;FR((C0%R9t&Xnc2H z4OYAnb<7q_Lu5uqSNT?X7a1xwyrEw&(j{)se48+Tpuk~{I&~yQq(G2G6v2BA+vn`o zeksY}2Af878y`B1StPj3Mz+r4nbG{>-+H}9)J!_QLNYKSCbTSbOkKTmz<2Q5bjahi zyZ*tifk4_S%~qH^bFSS?oCF0P-(F%u!w&73-Fz_`ziUX;jtoALaMXr-!@r4@h}!mS znEjPTy{D*^Sr*}fg&-c9oMML%Q{KPNTmUMRm-AJirSqoW{$fx~e~+!_I=IrqG)}IDb`)8PP?TfGC$A zfRQf8xjOtHG27tE{5V?OC!@@ZiB%!Uy+v8(3;7gAc7L-R`eXjG1b)avnM$AIc30!6 z>aKYn9ht>vYn%av-ZGB)2@nZpF4MBcI#e&NshZj@sW>bLqFb+p;>%5Ix(Eu{wD;j5 zwd;*Yy6XR&Y-lfK$5xKH$!+eh47CvrQ6v%xwnqdCx8dcH&t!OO8-zX11nNtFZ@2al zj{28EIv>W}@9%xpl=)DN!A}Hs^fD8!&2!PH&ZrbJ1HMQc+=b8L%Andu3+Xk6v%ENkbfSCM`s++~ zUpt-h_KHlu2xFn@pyCHNY%zE^4z$B|v-yknoBsH?rWnDitewh?CTofMyD>d{@9>@=rAGK{mOLcu)oDk;IwY5d0u$4pAfnR@{!uHR zS&|~y`YIWp^Z*YtBH(@}J>(vTp#@UiixciDOhd< ztTy;}iqQ2}-~!d(ou4~~x?IqwVC5n4Zutq^Zusuf+XeJOQeqi+3Z%vIhkk0YnNUjx z!#`qoViT$vMNtO~@3zqKj^I2&G>+v-MpP6%ii^my-)ltBeedzU$=tj5=>EE7`G^=$ zaPS8!-u1&=orCOzqIyqlyXTNSYQw>)W8dnWLYiL3L?0$gFfa9-ntHc<9siQK^{T{x z^qAv8Nx}4cM&S(T{+)>X`A@13hgZJVnn2a{6zp6r+hw0AwLTP2_i)X)xH2SvpyKS? zW58_Sz$#IxUFWsa+ORW`NAeua5T{f|muuLKC%HU-fPqrFniG*)v_Io-GK%earX7-v zI-Nr`JnO%{6Dw%bfhIqt-+i##8*Yre7fsscW;tb_&N1}xZ_`)7Z3q&$Y9p@~n>Zxh z>}D@p9Pl+I54w;R<}%lOIlj)@`XN!%Z`A351pdzQ-Ei$B)d1#3CaX`6oRd?8Sqqqw;AnQp!${q6*Vfknh2TZW{hug=R^I~FWZ26?7c}|Bx z@w}pv4C?p~A7r!;%2qd*P(E;RALgk(+uas6ni(ErpDY~Do@6tj)=eAf;vs&b@DWhq zoy-3PLI@S$IcW0iFxs5J392l0L-xr@#pJJ8iz}r)FP`wFHzrg&SHwItxkp@a7+&`z zOxUR@MpxDx&x)N*4xTx?(MmhB0%j9LbMyY-O7-#dgSgsdqk7gO|G*&nJOQ5hHUE`? zG!d8wxd}2_!(wWvlypB6IOR-Q&B?T?$ErchPFxx=n0I-^aCJ=eD0>*2F&O`u_V2JI z`#6c+>?0IUQY~cMgfI%qbUEnesS5R91Cr?94PtDEMu$G>>pg7saCtpr5TD+GAVzrh z?9Xpp-=}Ql3nojsH z8sq1jwlq^~_q}JFH954{8|a!3p7Qcz2T8amS>g`&<(z!NV3tunlZW*PbTj{!EHct* zC=AGh|JtIjN-Ljc5>u`eZ9DY_KA36%MBVF6^2@o~-Y@Nf zH*cYlZOm_ky4OcVHK>>&$n^E_#s!0Zum5BLtUf;%pK!YZ+IDQV=bi1LLHa90S)zKngNtMFb>)_FcO=~Ky+oMYYC)*WLt zre?ug2^p&d$}$Sa#D8vLNop>PPNif|JFmAL z`C#g!q|&Ewt1Uu7pCv0I{(jRO1-nzfB98)x&ZwaN)%%p>kX#lW>0_#FET(xkgdFGl zak%hRf$RAt5sYTu)H9nX)C+`A`s>~UaLDxz340yel zRrb_=j&32edQG*g)=44cf=`infeOW&fv{g$il}xz89GZX|DBN})@ZGyQ~T_MgF#1k zCFGDyk>o#U4)U84*M!-5+6#Lizo|P&JK3kHXtp^P-;>EfufJqf(4VqWqSQdOH}M)K zc!R`E5-D<-t;7XX(47{gS%1LwTDMKP9PYtimyrJpJ)4 ziEBpPvdOhYQlM3-I+DrSomN0TT|lOV@5_o%q%do`?##^oQ!3Fb%*_36@o@Yjfo3<; z;RB2puK0tC@nXG{!)z70?=_pUr?m(@i%J6V`MV@-OeS~3iVeghV-SNvhC)*26*vs` z2&8=aR~=)>^fPmoB8_5oiQTzo4E+M3zm++@M!Vp0xnI=G%fX*%1G+8?%^*kJv-EtB zcGxFPjXZ4N3bprV%mUjBE;`19*n8krZrYjuZj4(RwX5LN)Heuvww7ccxOVJj+G7%# z)5SXRBe1 zDygYj8qfaXET+;~abi4i7jY(?_46rt&}6cQG^u5Y(>+#i0uzn-P&?{;aMA_ss^aMX zO7I`1noCQ#XU#BDm{1)HrtGLK(hnNwN=w9Ms5?p4rZS1JerxI-7s%>d8R}wOjFO`|dVc!2$j~@ygWG zOKfzsqONy4At&WQ4a9fj0UhGbu7YkOei{` zCBbfYVqA9+b>z<)8g=p)Xo8^ILz#%H$-H8V-*lrYD9Z*Gmy>AoW>XTs3%^zTQ-fXi z(8-MJ%HS=if78{UPG?IUDH-YHqpi5xK@(DVtT{)E^OBys3|Jwi{`WZs*u--K!45qO zyekIh`m~Wmrp2sNc`y1EXhNADn71$&+X*ss1?}Mki%~eX%Q8Kyv{ZqFVUqf2uM6YJ z;dF+WDgSaAwwbU$V*uBB#?9M9wHDWGUfU}EF`l3*2*+ghNsJ#^<%%idGE{52JnmLi z4eY2%l+Dl{hwZDKm=8JUI%w7k*_!oMB4~Y6^H=iWC1>wuqMEivwCCVgnQLyutTgXk zmAf(X`c-IKXbx=_z=?b$9!a**D3LL|0TH2zFEC+W8h_ zm@d(GW(oJv8Z_T%_^81j)jB9?;)unn%%5(QW$nV;@mbm4d~{88@5h~qt^6uB-&|y( zOcA8+nLXFq;QN>!@k>g~kN*OJ5Fh)>L=|qg*Z4)eQ01O)7y`|pmlZUc%12{?Db^^S zG!scoiFa-CJ`*pU+M)M)x9dK7P+Xg~l8PBEP?EOltV|V`sT19jO&2YUt+Xi)W)6R5 z(gV9zvMTT=b;@FP=ZT6`^7h?rq3I_aHPiUywg|Tt|QeV zYY~?{RUA5*FflUMs1?-Guh_hE7Sftiuw`L69~$?($}6bHMer4oY~S;S zYEFMt|Ek*2Ju+H2{4A~e_e~oOO-vuko9=R&A-0R!N+q93nWv(UmFH+_uC%vO`o?)G z2+Hz{6aE$VFIS^3KNF~YovR!CaH~nKE44~6#ReLY>N#G_Gq6AHSC*o;Zkm?694+PZ z>*(NjD>y6jB$={WHuYQD;zQ*Y7wk>W-Wm-ulxo;Ng!Dg1ts7C4c323@e~BY~*}cC2 zp!{^*-{Rh3h>_Vu!B;3#no)Kf#zrZBzd65zAo-Xf*U(tivJ~u$TqN6Zz7M>{Ka2zS z9)8$k;;E9#K7hSw3k=5-nLSO4iMy5>$81%sot{g}!gJDt_qxfm5~nzd`R-kEWHe0m zyk98sBiFk$A1v*n6C9*4a|03WycJHgUpumL#IR4dtIc;WVK1OlYMD!U0R0FsE}V}e zMPP5~Ot+aGn8OITD0djp{xe%E{s}I_x)b*4uEd5g!_UEcLzXqq-MR2s)+zM%=$8nC z*&nL2zBn*jJj|@u+|Id!!NIik3yHJDg=os9X2;1AKA`;|^|)IP;jvy+KwUEUpNI=` z+}Na%d(Nigf=*Z6+s6pMB|{aaxSBQnm_5&eHf=&kbQ;ZNgqUZlm6Vg1Co?hV^xSoh zx$&3%C_c+5!o+=}+%nNZHHOaw8dhA}MV!WkBGq>8lveIY{s#>aR1cH$XTmypt~U$C zQvI$-q4A!Mfj;$Oz!YD9^4i>+!~Gyr?Q;-fgGnlVg{}#IPO*d(pn}D&?*g<1-jdG7 z+xZw&!r@Gmz!8iH67^;rbz>hbe!Oxj?leksa`5(`{-XZ7#1=2Qjmgg^?z+LsvlM5| zjN@>sd^_&|F^a@RWpx!l;IyDRn8P;mEusNTkcgZq;0}3L^D*|1)p+xLqFA`9UJ4^C$GYi(GEtq>t zI@1_RO*Q_}8ty&$`-RA((sDtX+Qd|RFE_BVo?}fc9<_iwgTJKQnmM&D$(0(Ifl*`mo9tRB3{QrjNi0ZrBzD`f9DS^r5=)L9;NI^7&&ZUyKqNp^Y z*ZtFIC$XBd+2vJu=AGgGlhE-iGtX`*bM>OmujP3>C6S-QFXv)t*E$*>rT;k2-&I|r z>EhbDGLv2!sVO6K;~_2P$BrXn`!ya zlRk7U#ah$%dI@68wx)Ran6Cmx2SU&*cL>mFKKN%h+p!#yY^GH@%>ulz$%5z`?Qkf0 zJf+ZeR*K9-TN@#Bb3;MJEZr#s z48(Lb=4kxzW}NOf1W>~8orNa#w^%py?xON55Ip19#yzRmB$h;3-%gjeUQOUzE$hs% zadC*?M-U>ehjH}oBRd4In6GN6%(#mrg&B$9k|0$Pj$o_#YEt)jpD~k;iLeS5h}clo+6t|aSO5SVgJ}}67~UIlS#y2siTB|nU7fs_ z@1$#86i@Ev6o5;K)9B^NNWVw#Pv6EgzwI`mZgG^@jmpq+ z6(8NrH1suCuGRkHRj#@;GUVNI^3{nN>k|$!^*ifdeM@(?&h6|W(p`^w}%j`LK-3KzQ9C}5aA;+_&nHKP5hxGisoWHvbyJ-U} zSn*0hG>z<9mr^iZ$om*VvnOd^|Kf_|-J|H9I#dedDW2DCXW{C62}s_A{Z6f0s{O9& z>38JK)BOFCZsOr{B>P6(ei+FUL&JWn`yK~-QIdky+F6oQilK|j_+T-w&o6J)RLmky zJZ@1{7tM8bbV}p%d5w2Ijqfm_XKLV}Tzz)f%b3ZbS(je^`U~;*mwZxk2Qq2)#;#a7 zIAvUYLod)2OOG$_0_HBSo^$dIjtQ^pli8@$-FIH&9dYW$CCy(DuAL$sh3TE?H#DC0$NxER zD*RaX=?STiR8x5N$2zY;;=%)IhYtKt?zxX7${SKx<&TBaSt{bCm1W{ep-snv9tATN z%)#*m9baQE8<1p7X!FG{Ux}J~cxGC>cih4m{XE;6a>+2Adz6nym+QZ&LHd37r37sa zruh)d>JCrj(&aMR9wqYynNN|WWvk!%C?Z;O$F-;OVo2H|x6jab->mUnE#Hnv1g_b;~oJ4!4P#sr7QNe%k#UmD^q7*JeNk?B?DmL@*dBSToXc?TZNxyZQG_1uyn_9u;%Ew%VbZvGB%0|Aj8;@+^UwYOMft$wH`VVDWRjE|&hw zPdK-)=kz_~DW&}Iu(VtyZ!SqFibjCJv!tTjFX(2YO^qNT2Jt1({}Z6l`Mk1_9&`D0 z&Ig})y_GufC`@WoqtM;eyXK4&xgl34&T*+}icZe1BP?Sn{~^>M z_%fg_D3`bnr6hUu7pf$5>$eLCbY1lHCY>Zr>{lN2rW-N~sKgLIl+}69TGE}*w>M8H zE)nWU!(Q!i)oCrcMpjDNL97>LW(=R;w0XDt+QS?0_$PU;d5PARzUkUHOXRS4gYtF! zXsBmwYhSbv9fT4`TUii#JBr@=GKqV+g?Xwn<2X;4Y*@^Km&P3H5*`?|P@d=b)|!|} zqavJshV%CihJz7&_qM^!7;6*j+;6sIl)Few!L}2GY1y4mvElQ=EruY~kIMH! ztDtKv5xQ6_R{TtS7G2E0^mz>JNxW5?Ih*0K2&Fa`G^wnpYxdWdb})NO--sj|5FuL2 zI~7B}2|M$9q~`QabjUMACGwJ^=CSNOuZ=3n&$xvDAA9c^P3POT`-%`#2va9|CrXrP(Wf4rV2a)&I>8iu znmQp$bO})+M2}vgi%vxEJ)-xXb$Q;s_WHl?diJ}<*x&XT`%69;;bz`fIj{3Nj^E+H zMEIw$wS)dIhh5MGVz3HDXFRRA0yUru_?gs&gZ6;tf|_(OSt2f2tE#34;917yJyRm} zXB=X{pIX9EAkpSDnL1MYFmENk3_B+gPKOs*5nUJ~ak0??dpp`n%NR_g{z#S9fA(~1 z;wzbwP>G3>f+~$+3wMKTZBaIdO8h376EdVlDM#oZle28nZ>b&BmkO*qDS( zyM;>tb>$K)q zBZz6z8Xh>aWBcrqWJeA|NMolE>Uy=$DXWAlB5?D_XD1G6g0XsxEEgL&gllcU1Qm0p z^4V*_d?qx~(cN}x&3vbjOOq`*!RtjCk9&+?E_%G~Uft1%GL9}=sd$VTnYVN+&-eG- z0Gx9FGL%1M(MznR^vh7Ul;N~hlF=KN`KatjB{3vDMG)+nBGD&4g?1-{4uK}dkXNYP za8a8=4xRRtJ&kC8F#F_Y^R^}{sM1uz=!b4;DQ0OOBf0Ck6@-NIGr~_Jg_xe9J6*NGj=$4dno6U!XFBiUH z)5koA-6o1OaKrrqS;5>zzXIn9-0i6OhdhFOhv|A}2b8}fp+~^m)i43Pn=pg(7FWz|_QKdC0D&@JjuygmpPia&x|rrGl0P<{K0(V4^ZIew zYjQ)_X>imVr9^*_awB3K1Es&@|xVeJCz8Z8tWI%WNGe~YUj_v*9`Z_ zkcg7Q8PmJHi+Wy71tkM`Zdv1var?B@!$C>O1j>WRA@$*%pDet1Pxsr2+K$0mL3BW8 z%%^AJs5EOy5Z=$P$ff{UG8UWb@w&@sPi8ABvF9Fn>hm*gj(DxeRBf)`+bANy!h-Ce z|GIkKZm)GA)bz9URnSh5r(lWKi~;bLwUKi4snR7(A@6inFW67_@Ja5sKgs-dF84@s zCjSFW%U}aVNqyth#&66+k7KdrP`B-^Lke1~OUKK!YtEJ5SmqS_g<}Ojs%%|q4HjLi z1v^duJ+Dcwmd=s=Y_nm_)0a1;{MYd3A!zVaGZ*;vPu4o&0DIxO%m@xKOUG$KC5MEuCJ6fnylkKL>6zPz%Xbhcd2f}O7Jlk! zeTK1~(nA8-k=a{x6SRA;6Nn@`bMaXR9_`+={{q{&j-&d66g*04iGYMH--={RC4 z^Y0G7PY9}xB>uzSYMM@P>o_1HbSmYzd`WHqycqx#!}F%(+t>@D;#@z#q87+Vgccwa z7E|>|byisg&dpn^LCPz>BCiXT$_hMjJ(|Vg=Zg1YYvZ#Xmi=2AZGHl#g#9abE+YPf zYMoZp0A*ZZYtrm#{Ks;OK z{{)D+9RBpWTPfq$oBe8PQhG@F!)b*T6t+mj^Eo#b<=G3{oXkf6-%v{@`S8bG`z`&p zAC!2Y!FqzLut~R#dIz}{7p28o3SfV|oxuYsYaOw8Cg~WWgwr)lxpQ?qT1$am6Y3KE zFE&hYF<`?0M}eK+Dpj*BKkrS4S7+%TY-!Z-Jr+O4hQ|@=@yYFk@rEyB_8DZ1eZ4bY z1%*{a5Ipq=;29a(o)b*8EbFnR=pM*ospvpR;QJR5Ht6kEhSn?mu?CUj!RHrB) zZm}M!FNu7N*+t38J_nTn4d&9DUzN9$`AA6KwaQU|oe@&?HI39W?bH1Ii1n6?^BAv7 zlK|{$B9;yErBlnI7NN)o1+m$5+?~g%o`>$2x2S4+#-7HBPc-|7>s`bv zf!kw4a16&ND-ao?=gC4ENt|(^cL;$4-oQ z8;%2yBA!?F3J7l-J)g~euz8WGYvOwNq5SI7_2nWRjZ}9xHiqtN8J1DsDZQRB?D49( zH+GjI!c`RB4`_B4bHLHkf%eCN#1VTFt-0C#NJi~ebC~*)lG)1oyGlJy`6J)={RQFk zgYUGT{uah}pZn>5nU~Yfy&dQvs+QRj>{(Hk)u=#%9oNChTRWZ^{M`zNaVwQNZ9~#4 z4|oi}7Qg2U*dgY#diEVRMvFb}<@6Kn-y8n~cywa_Ulf}{?mv*?XZqJ0s~{e64p8s? z#U*HMV6i6A1kIjTlrh9SM;2x}4`}$8l=i@Kp|^05C|#daWxM1O7_R=-GKesMgstF5 zpnCJR$UQZ~l{d^HyCLQHCL9*_geCrPJ0&wI1zYac%zft z1H>n!3W?=CtsM$s4~jZjwu$#`Zm!PFjtd{Jw+9l2mG?w^b%7oxJU`~ni`~D6qt3QJ zNDETwgLEbgt8KWVVm=kT3);QY0$EM{D_AM*Ll*Uu--svYV|RF>c>`I5Zd4qiN%59k zbFzo3=AQE8pL+3T%S;`P(IzVY0lqenL3z>fyjeEWRu8^Ui!;~ zk=pzuUiI}HR|Z#%ywPDtBcEEH-nX4Vff(4Qdr)Nr^F#Gts-Ij4kEs&fy}xQ1Eijgx z!0ATT9d>lgZu^twwL_5B`Ab)apQ=I+4)1&4!}mgjswz#_r9>3|5LgpOq#wJd`8|xm zg(B%v=$gd4J4~vn_QZ~Jm1f?v*zy#&QNH9{p^RaGZiy9qvlIDfl3Q|$E`3K`R*-uZ zSkaWLORCB^T@sGvB%%e^jXJTQz!~G(i)|Gs1_BP!SJt%q@(b!~C6FGtyEIMsT|`g^ zn#>HJ$qLWTHVOlWGv46Yin=()_i3SYP59Ry^DwV$mC~We{SSlw8G?06en2+ej0o6I z^|C&)y}(rF9queWUUXacv!BZSd$?Z_i$+L3aZ&4=uKrhL`Y>;G)@eME!Qt|RW?4{v zhkI%LV1;CsZkuM=ti6i8HK@i*>Fs4Qx3A;0M~SL;Y+Lv~_w>v@4Je?Hb(PX9xxL;~ zf&=W-QH?f}!%hVKS2*ckFkBaI89-Uall)P$DQQ2~I!COxNVG4k zBy_@RKmEk_=|Aa--+^usn7LdodHAn=C+=Z0;`CS$Ta&Yg(ibtb#zrg!l3?V7E;3p< zQ_ElEMC0gGaVxXyE_w(n6 ziW^rcgYZB~Iq@E&s+S)h^iWh?KBJIhLUA;xwNG2<<_4B0*l6}-spQSBl)N1h+*7sn zoGgFq)WgfV07GPcqHY-ydIf0Lu_~I*Nc@OBfV`>Go&O;!t$_*%#cjJZ2}7jbXeLpp zYs^uMe_TOHb)ICmJ(N`aS?KYsO4&PrEbsQp7xP}ztrO0#&)a8fdSd3coTyk7L54%v zOBpsva%DF4wz!m`dk_B1{%KeC|9*o z18jS1aq;^)&8Q7-O=TA1D@yE%J*YJ4VvfA~C@Clnft~-`y0%y(Z&Xu!neaU7 z!bp&|EleJ<_Ao)F%6L)qeKdPW^ekKFd|rckm-p-6fAbid0kP)wN;L>4x}4$OHcmVF zxP9sFRcuH3M0@ThPxf0rR*MEDe~e+PnJELYtTI&*H_Wu*}8>9>crXazy_*a$LKm6li$!37Q zgHbj`Dzsy--T6op10Sm{}D-l=zkL;MhlrSkW*pR+WhQMOtiP5bfRIi7m zJ6O`}36XFLE}Rl?@G$|tRsILRH)XB&If$WwegHl~MteEY6#-l#rWgEqRR1Wk|4Wb) zB#=N30B{Jdd3xUe*N>@9dI4m~JCy-N<~{S449_`{bXnrU|N6iF@s|E0Co0hP7O7*4 zVWRX8FY$kU?45rIw*K>P7ua;m#Bq`(`S5>y(!cv5$?^bdlPbTLSo#0z+czk|JGzj6 zzW?70(SP^Y|Nl?_|MpuCEMvhC-wVX&+yUx@B|uTpW1Imw$d@DlJmMR1+X9Ntq^$mK zS_<%Iq<}}W8v*btAEHfS*3-=A1^PQvwiW{gcWc2^A;tIVmWh}L#2N9GkH*CKetSWY zR`2yeZZGX$`0gFD&70F^|1wYskpN!7?(DC{3=b|WhbE<@YOk|4T4R81@Mpid7;hGQ zyG9U$kp2G6*2p$p10P?*+N(=&cT(mcf*}ZP)#=NoH46@-uNNqLJAML#{?f^`(_=M} z)dXFy+-tFwSee#|g-DYta~EJ7u8L5=@8`q-G-om8Sxa2zlyeA=qxUl9Jo!`r@C{d- z0d@_~QBvyLC!sf5;;cNsmK*CP=bhUgMBW-w&1hbONLjT%>8_wq?mJS{P&y5;5ys)9lScH zx5nZn=Eq)2iaU+Fz9mzI1?a}V|ML>?uL<^jf`LaD^-)JxKNoJ3;T{33E#Cr0Ee-(Y zp%EZG{4@<;56fY$mq~db0H^y!5vVc(kM>Od9-GiZn3Dg+1L`0%F}wm)hX`8A-ub~d zV-*nqTHl0NBKiOih{Rs`e49$#@h>J&Y|Gj4f5!wO*U%dIUpydfO}ygk6;?_1xr6uB zxPGVZ2mdV(DCIx%fSzh}%b+h#{>#DlWfxf1l?JOCrJj}`IyfS3k;g8n{$L;$wk8cAjhxnptnC;?y0G1*pw3t{8qb4$ zEb`X0-|huAG~yEP**|gnlEBBx1*kyPBnem?SU~8k=hF5-h**KjMV14p&zEJ}a0d2> z#|@>7E(j>@NTmjlIjsq>YVvT1L|RVdg0@h^mRlY5D~s5vK2>6vQ_*I~0J` zcyDVR0P4&^is9F#VVO@cQsYf8PiH+J^VI3jOI`YbE74$=pV*s8(vt7%||K9 zN!(v?X|Z0$TT^v?{yPVvH|}5XcdL(*T>N~r^yVk;*=y79$+R$NE8?BY>2n-6vkR^s zQ_dBqsy>Hh&kwfE6w9~Qxb)LL6`-5tme+_pNR#@wGw~W!O^?nZSHX7Afd`zUXRg0@ z!4#3#Ik^Wi25JI9r#^<+Rx`j-kQhP12q5IP~ zIoJ>)EA+CJwD~J*{a6Zr{5W*WOum+lhTW!1mP9^s?!Be}o4kb5hzAVeN!+$HZ#J?z zfR1wIQ`&1hir)eyUi~?LjJU-vCkMIb0Br|Pkxqg;ielx@W?{)mQY3Jyluy`v@MSTK z^#8cfu@|%W8UGwi28srvgpOmt;}MD#U*dtbs!5!$tcbptxP_zzLd~1&scs8q2kn!( zmD8e%fiFH(tvWqt^`Lo2c8gj%@d^_hEtB|R=3>rf%WH3LDdj2~4!rI)!W$oh?kz4M z*b3QfotnoHu7e-@&_l@!`T8R+oP7Tnw0yrO-M&IOeXPTv-WnWzn-boW1N5%=~u^srHoKfbjHWk(4=JMdMZZ|(a z5{gnNBx~9WUKB`aE^}__W?F`A(5wkjCDzcdrFpiE8oqgcP~h`K28@>kiS|KCdd-oL zw@>Lm+x=yQX;d8K4E=A_*X`Q*;|M8Q~FN2!#goi1~x`9hA;4c||nB`D`(i41922khW%S+c&Tw&!)o+>GT`a z?yr2abgp=f&Z*0V;F z>iJzQ(}r@fUx`+Vum6i3r1AdtvJP3ZzZ1k8{M76q3*YziS&v?mxn$e0zjBp8+cL!c zE;G~M)aCLj25#N6Bv+{^iDvg(`U@p}m_zDbI%btm8&}mtc0hXAP-ny91E=H#60||s z?M{Go%ImR2eZRySft&3^-^ODd6885nMl6;r9vEiTZJ}0su7Mf@^v}ze(n-<~hSF@b zC4hF+cCK=bbt1pFNV&j;*W&DPiwrWxiGhw9>CPA+9FgFY%KuGGctA4(hU1>Dbkz!;roTdEN;RyjWas(hdw90i;n-Ah)=Tn%jyBTJ3bFS3LMv zhHmMfslU7)uz3`f@6m8P>Cv|z@tOaYiOG}puO9!-kS5hFNin{vvb8k&T_^4|Vmsi- z0-Zf*p8VLt?5``XEM&UOl#W)<42LR8*IuEeO2Jq|tSn+SOvgUu5R?v1Pz+Zzj#(%; zhpk&h*pzl@gtVi;dIrXwu7Lx|677j($--^=EGzkD<36prA~aNmEeOg!LBmPfpiMv% z)xvplk1(|4GR+B94sFxnWSck}gUlHj1GEYr(lnslRm~6nYo~gujqcu^`(~6939pTE z0l>=utY9fl{X&N;f|2;@yz*>;MT^y3X1(GKh$f$AK30>= zkj_D@`QS!i@a>G*Np@m`M#FpJ=S^_a78k2!NBQj>Qu^dQjILeG`;7FIuQx z4m9)|QDVgmTXNB$aEzj+>)??H_z2l|77QnNxQUnu48XCa$Z9??c%nvTJ*GbP#PV&XL*`MGrJ+3JmFT?>Z#|E^GPv=j`#dKjqWAS34JLknm$wy^0jE1_ zM6~w?H|k?SyZrV1^X#vc|NCJVtW3}U116-GA?msFo#TE^7D73{d|kgWTJv6q01bn6 zVd*MKrXv*Bga${fqp11jVv!~%6c7+Y-EPjm7Shq%SI>GVnfEQ+@zi?1_8N)=0HHyQ z$J`BAs0WAWM}V0q29@n|!&~7!BwjFP^}dfC=};G2z@Uc+>I21sXg6i1{M`9~XUIJH z6W&({eNeq%84K=p1`#+;XzAhsaVdGWAdu;`*hyjNms|c=@QjZK`>nSWvBhNxfbP2O z)%?QjQGfOA#P4#OtO0jlD&>?}j5%3|d#RNuII%<8Je(-%SAv&}!C_i6Ohrub3>w>8$VvPV+NN_yb8$dN1VuS>to3c=RBJ==If9O~B9U|#rSL96gY2b+mnWn&Asj9UP-Ez>)!|+Z74xcZ ztw8>wj7s^pvuH)d*&PfTbekzzxl6dADCky2n5)*yc$0xksO5RSs*8Iok zsfU^|ly;K1)!W=>Db3~f3SZVUGsT7fmt$CHaDRbz{ynle_=OeCT*sNk>hi`k>oQu+ zP|4nG+f}j+z)(u2Q8{sCJl#cks+z=(mpMNnzNvG@FHrM~*}O z8iXF!=3gnZW~l49NxB;^fjs9vh8De)H2&-)#(yp79##scTuVCymBz7j{JQ(PG!v49 zv%7qhmUH>wff{XR9uB8W=ZKs*o$4CGJ`*i1QO{V!0G#XECSV2@#))x)ZraL&Yv(## z7y|u7$S2#Wr;uVFkZI!;6J@kNGqn6nKP}3?Px|X_+4`!YN$h5cPqN>yyC(NC{%z|i zxf`PL5C*j*ar@ogilEke1O(NZy~IeO^W3*OlNxo>=2GUtY>|#^1m?_@)p(Yx5hTkV z;_jS1KZ!^;;S-9h+N%$7{HqYOM1BoR^CMrhS+t1>$=v2@swnNG&s&z5k00iK!j3$< zmC~O5fIWJ~@R*Gia8pl%l{txN0ZjGgBsrbzR}l9m&3!Rachya}bE&Y0?7o=YSkWF) z2iAQ{qD}l`w!x9RI3>5Yi;%bF=-KdR_Pi52SrZD_+APpU4WH7_&&2Q6i@{>Ix0qB( zr`RjV=4nD~PrZy_&}Vqs&nrwHc(t{k;0=#t2?7hR1 zw~oJeHsdaCusP>Kq#VZ@lqDf@Kg_>M)`oy2C|_jLQX1TNqMetjALa2(zC_!2+|F0u!?dKA3* z`@75~D!eVTp}l{C4wjz(7#vaWFR9TJQeT=tsQz^Y0ZCY{wBwB{{o`~7eB-9vP@LkR z-yBdwyKGzQF0l@71z%lTcB|;uth=&nk(fT;YG_3thm+*a4 zgS9A3df|Doo4j&2JgC-)B+F+mg2I9vLkvVEX2nQse*Ofzd^X#qN()SRNl_H+IKfA& zf+popd_KGy-%_L$*(z;A59}%aCUS8SUT+M+;ccq%>1#r?=Ter1&FBz_hpGr_(GT z1T)eEx&*n>&jnX}`%Mt&=9m6vv!LbLd0<;UX`Aw$1R8oS!ChbTroXFCuOA=cO_wgT zxef?H2b1Gi4(Ub$h1~(iD0p{L*k}s-kWUclWov-180pTu=Fl*muyVYsx|Zj1aq06fUu(5-UROlvFiPG5Hy2N_Oz!Ry|~ug$oS!7eCFbK+eH&SI6YVq>_c zT8PvRZT;iPS{oEqtAVdu%Tr093)?8L-Dg?>{WgSa zmtD=|VsrMJAvw^^fUf*({esz!AO95f@ir_>6hQpBHp4!NR~Qw+Ag#Gez$`99?CrGm zXa@;>VthQ7>B$)b>UK7U?}<0tfZk{4BqVHA^bEp>LszhFuGhHTdh9rJP!#+TCG#VA zp_A8&mpO4zwm#%rYK+ecV;1~qc>%5Etsk~4))=*hMxJ3E0k%~^EwG9E=zVTDKZNA|J@~uQvSJ$Qe{9;ctfx}ZT z4z9>f2niDOGga*jtC4dF+0Lx3S*g9Hsha8p`vuOGHLF*8Wc)1p2eSP2c1A!g= zyCGdV63u4Yw6WrI?(Z*DIiUlbz8BCbvN#2ZInAqU(#29Am{<=55pJ@#^g>jwV`h8I zkGqvzVf{xYnF}q54)@2_?xA*-zTJ@kBWk~4<6GlLg!G}b z4(+JRri>Wg5be1W@r4&-e zNYWPS39HY-W3Z21XmoD=diK=PHju*?T{qdjNKIeT;X>^@aW-%AP}AAkT}I28VBI~0 zAQQ`$G}^aw$ZEL@wGTXIuT?m?ykVtnR9pxa``6E=Sn9?et<}5_EW!!WFd6t`GyBy> zM%1SgGny8kwqoxQZOGZEq-51WabaK3(y5XG*cx?T6r5%}dl7}zU=c4&GhR4$xAo$j zs5wQbjHT5uqq3lW^g7|sI*Xh3!JheifDXt6J~+ZKaJJf#eVI1)K81B!EWz6d?3RT^ zwC3r8uKEOkH2Yy$2OnV_{_n0z0;OT5T2LfBxlE3N)O^zSxsHZnnGAIbsO3KgAAR|a z`$kxuCE#f3Yr&p<)ny-txeCQC0w?Msq-_Zx;i9c`5-M=Pf;{kU>C;Y?SC|^ z5SsHDXX~Z4H0>%+y%ewdz*3!lwt13g5gG&EefxxG&^-IB$Up6JiyaJCa2%ZSyoqN7 zmGCW8l(}eUv!vzv|6~EMQ%2Uayw*Gw3lHTJ_y)x=W@9cO zGex2I?|!!~OFtcG)v$#?c~F?lVZEp%kWMAo`&{PVo)`@Qx#{eSlf3dDy1Z6SA;=Pe34bk24+#-fSWzbh-aaLv&w!N zn+$EgLPz2uwII2o_jQ<9G&`4(dVGA=(wY0B#hN+UPdXZLNu5=~Ld(erW$YV!4dQwj z^K*z_B=jdptlXUh{Bk=+K!%xDkPsZmf@k;drNgaF9i{z|3v=tY&9LvWylgDJ**@8C z4~;i$4#V7yhZXBov8AmnG*fzcoJep_m9I#`o$f*qg3g@oQMQdJQeou9!KeFlTXX8Ijj+~|nxP-@97zgBpaV&Of z|BXW7QFIrnUtD_ZkZg`puC>QfK{x3r?-UjB*0zrDz+$i0DEg+@#y3jJ4&;pFN?By< z1lRaH15M3gjZgHN8puYRIBrCgdI!0ZtNF1?cWJ_u7h)=XSz`mvWJ(t>ZA-)0h|%l( zn`V{;O88;Wj*qJCq*y5&GJTaUQM-34ZpfM+8-6_asw8URh$7*jTvuxTE!Mr}kwXyw zw9#Ee5IeLQPnj?ZF9GBXFmS5I%GBkn5Xgh4F_Wo!0}uvS?AeLqDJf(usbU_!^j zJ}tB!=H>kJPdgOMxC4D@mDpB*0<1-QBaBBgqLLAO}(jL-0 zv%XjRT?R)hWt{2^6lQ^*ZEvN*6t*(0Y(8Q?qwz3FMYa_SiM+AHv_474lusyOjwxaOW*hnqkqI7KWP9HQ|Gh} z%rUti37BLaw%K6{8VMhHc+IlXgRO{{740MDTU{zUOzU;!A$-ANk#%B)1b7j^YNtRi zM+cqOSn@6lHZFcwJb%31_F&432nJo@>ck?puC$uopB13*Ht6iO3o_mx4R%lRYm}US zzwyc3$}huJ|ApK$vWgm+I`&{nQlnC06lko>ztwnM#y=COx3IK?*^wH~D%5UCkMnay z(FFE44Aw>bPJ&t;l6{?hWyVR3Q+5+tX~6+83>q?XJk*H0tR?MnE;6ReS;lU<$sp06 z`HcjA6;e$JMFv6jh(Db5n~i@2itcKzG4NVR9FuY&%e0Ewc6es_M@o)u@;=`q>r^Ek zVLQMMea@UgG{#Z(70*?iS?7m{L~(SyDoM7^VWm%Znqbf`ipL@PTb-jtQczMpCba7n7gIfxWYc1Ew-L$^0)E^tRIh+>XQ3XbT# z%hUn_C?-0Vl$aX4cXtLh`CT^qg4p5-dX}P}<#eRRyo-)j8FY`a$#^4WDEXm60KZu1 z-6Jse>rNgBzejz1-sJIMB;B|9W3U&r^Ao1DA+_ z$IVveo^bNoHVz3FZ5M#-MqS>}%CdMgek#HgC5{HvmoP0q*1d4n zOySnqAaz#vfmXiA6R!xF8RGQUBT8Au@w9n!jTG+6L~%r`IX8@KHMUr$uf`{SM-gV> zlqarI()Mmj>$%5xnsH;Y-*xZtQ0TJix!evX_v*pfZ9;jrbHCUm@LIkIT<2{$&iHi; ze(}&XxMrc=Z5nKyco*!HfZrgb{dr1U-SYd_Y_jeIw!5}nkZsbqJNMz4B&YXT60&e$ z(CZ?CY|ia$#)!~z>KeIy?bB2!vXl+etA|rX!>Oh25q)=ka(FMwfRpqm0db@J+kh2k zkhs#(a`z7D2rXgru4W*I;YJ<)msl&v($~8~G`}ruIjp_%Up+-t_<`kWWmaT(TxRQF zi3DlvrA)3ZKa|h6rce7rTvUj`RHgMvhnn#R>4$Qy@K!a43ydPQAx8pEwqaeDYIG%b zQgz6yR|H&Y{JYh(WMxHU22=+ufld0cTj2#=38^A;>#1%QSCZ{^XfRBI!w0wX-XD__5B*onTIp{V2Gm zQ(B}yxy>;ACbkx0$x%TU#}JzJ-7*Tv^Y)hprOivBnr7)zC{g^m&v{{-n@@Vx{oyZO zFCKB?9BgTn?%!>%Vop|@g?odw9BzZOKOdo6kV)~d!O|g>Gl}oWgH;{y0 zEjPJFx!W3DMhwBUL&z`OBOb2vlD|zAN_@kEr}*Z!%if&DS6}$st%@Vo`5L} z+AH4j)c-cB3zraGMitUphp+1gLLPfbXSO=Bs3w3EG>GyJDZOT0RhgRUZ@eXfB!e{j zawWE>JpjC7N*GyNZkkUmdKTV>PF7zJr#d1HG7mOM*Zn27jJP=k{D&dN8w6&Nr^rGZ zSw9iN810nLSlSM3){{*vy- za=+1s10>?!WH3}`L3#B=GodRa$^{L}c1;7YXQo)9TGfp|)jvid&$OAmBK*`Lk0|3K zM@n1=$EQICi)7ogaIz^}(B1j9_W92!)&)0lRRd#Y+DY&V!Ryw3yc-?r$nGzn8$Z$s zkyTo8w|$2pc-rQh}DEd+}5+I>3_`~ z;dY9VJ=h0V(B;9*rjSkI?uhB|(p9&=+2j(MtS@@;zM&thy#$=PUGWg@E6yCD{exFR ziL=Wux1TuS9-E0&mv@XG`e}1Z`uF4Ly`GI3_oJ#7zGdxuGr0*iCo7K>&tY4t2o_DG zKaDZ8S}ptZ_!I%yDb;ic66`dx4avd?Up=BGP`}m7M;j%8yx|XrY6R-(hlsHPteoW^ z%TV9LrelZ!i_ThJ!IPqS8f6&Nt;mAR5oWrh!&D%W7F_MY|9_qt$`F`W_07efP|q?D z(<0>@<|&qEOe}g@T54V&dMgEr(K60mdax{T^)1bu7*hxb>2)7&klqSIvO6~_Q+kl4 zV(&A^!862qd4$~7Gm8S0c@u~N9W(>`H z-FWa^nbH%?$w3HZStLe}sz2&EGlrL^e?gVf3pSd52eXtktkxtYJzoudiGs_8pj^J9 zmK5~<)?w?7pWm}ozOWfZ2`AtA$tWou8;}Ky(LA%1$fOQESM}*UCAA zvjNurB>rCXt!u^OSEz$!JT< zRpKT5r258ir5XE*{x`Za2&|(cW#&qh7)%5mJ_HU6bfRomq&m#&wP%he+0`i$y##k zhh!&)ldtivp)bAOM=O(Z?`{aG>=&odc8-+1fhNiP6A?m(@jtH)anaFblpw8KgrXr( zVqQ+vI)VO`G7NdMU zx-tT^lU%OOgd$fstykF*%X?PM`kX>?#lg{u2Xn5F$mcp_C5>{7yHCF#*t)fU!Cy5G z#Ai29ImyXuFzT;LOFq)t!K|5kzBvY3^u|BSvy)L9ElU!+4`Ir)Mr&;T^n`RrMm3|- zb)`Ea?SvSpa-ctdnm~UO8hQWXPIqDr3v3cYi7mH1W*M(%R}e+liFX0v3B(P<%;mm? zg+ysM&$^FW0Vd!P8@xbrl(D_OfttDViT(^M(kk2F{OzOQ!Js=i>E>o<`x>d2*r z?_pj+wI7aF?#~JAiZS~&wTl7hjK!nDaG(AnFakAIM%MbclB56jiMV_q8~0?C)sQR@ z;#Hy~0yj&B&=vc+49MZDyr-?UL-+g*4?uEik1B?~gwuNlv$fBl#dgF?D?!~2I~T5Rv{UUOcU z#>~BC=acD8n)&24$6q_qzlLKn+{5UM8i=}g;O>jbd~17~7CC3D6nfeX ztHSooyrj_D_#VNG%xAtZs;)^_63pSmjXS1P@OsMl{9WBI(#BWpGVoH>7h_VQCKj|yEkWF3PnfwzsBN>N<~2D!Orv=-5G!ZEXQFF)x^w2r zHA6%#(VcOvwNjO6?eD%&!0b=We;ekhV7dX3*3MUZ5ZNHc#6(Bz&Z+huoAxldzVrG4 zvizHUzQ2BI(3^RKL$h@jQi~oHa|;*io&@&Aclv!3;6^}!&A5RwI$<@P#fx+?68&AU zGn4wTCb8shCRc0jh?Qn2=5x|lUv^@@`CJu0_h?!kD8w-v##82oio`zRZlbeSO%voo zsk1xPnsFNnug_|#*Tu3Ne{`7rGy_lFyN~Trl8St~TTAz*G`TmM%AJP)Ahvc2QjF2p zzepcS6<79CkeSofA_a@yNl}bV$2^eS-0|&__tnW5Vv9N(%`&iB#RH7(Ov*b`4@p}> zq^F=rgrC!PWE0k^z({Z~U32_Yedc`%vE#w8*egzL=SxJ;>$m7#oBlMm!;-xcpK68#%Xz{L{2%TFWSXlKzpJ& z52Ej9oG|`=lMu-%=8iAqS^2K)eV%MA_gwgH;mepP-o6$uKS;KhZsvE4asYXx*d;&h zd|_{_EqhkVw>R5rh+!sSzV6Yy_0qy3!z-4RaGk*7>NIPc(=^qHO@-$Yp*&oohrQcV8Bd6rs}6 zy>omkuZ%Adg<8hvQu!nOC zYuwfcU=A@HQY7L7=Un#07A$q_E^Ue9Y=x`0mE^eJex!*d+_sx&j$|j8x>`7mP_*P%DG!1b+pV8>`(#7J+%tbx->GiITX;S>qLxSR)8;0?&kgLiDNV2( zLrv(bo2+yYId>wVMlsg+Cf348@nE7)XMfhi6jiWR8#97Lg-*1?OBJ>Js!H z=tOJ;TG4T0q(ouTcc1D&(ac+;S2H}R>|n7@X>6E$^q+rI&45>PWZ|(% z$xM>pBcKjJa)!rEMC#Sagqb%*BO=MH3Aknyuau+NCEz-+Sp7u?$5(T^F8OaY^vrpY zF-{*`$p22)A973=X`G}>%w`{gVP3V{9)EA^5-HN`a1*f5pPYKC+FO0^&<_JAlYM+h zZg6(Rm>LFM4eEY1zZ&K4lE}6 z`9&28zNbXZJu?Z;Wyy)gnUt+~U0<}A2LXnY?X&f1RT}!R^&JIq zo`ydIpyyCVBlT6`v;fZ z3n=F0WBE0Tzm~S%r)Lv=tKGxqSiay2xbL}fR1tP;3h?wNxca!3yib|o>S14#5OL`D zwG$i{dSJA9H}58KJQ+czs%<7nqtxe__65TGU^y*PO~m|Gd#5W}WHpQ2dtFYIUyQ#i zmLFtE1dy%pdh{-4c9lkcrOQe%Z`lKxUW3kZo7n-84C4wCq zhxnzV_drvFsUjem@Ibbpk6^$4eQl?=tY-9{1n=)9jB^`+2UWu)EDO1|0cw}!G z-~pmPNS-K`P-N6Ia!*t*_Ea-zyF5WRL(5Vd`cPWau=?I0q{F^Y(#!FfllpIiA%|9{ zLHcM}6to?k(II-a56VoBw)pnlg~OnvaZmX3^ZL3@{xU?&!ZVBxbabzldWo8Y9u65v zZCMCIrU}Nn&9&$VR)vI-NTp!bF&muoC`@^8yi{3|&#OWILimjO3@@`UJ8}x}H6- z^=cggsmRod#$zX%_8*;@sf!(eyqKc>@db{eQkb4~89%vJkp7dScB0nK2%chk2TqJ% zIsu50J6_9t37)N&hsU*jsUP%3=pUdYuzS_4v_hey9-L1vwq{f&N@QJ;D zP1nnN!q8XJ*H2?ndh9S`_Y}*-B3b}BO}X|e_7(jN-O3L)zFoQG60bOT&BWdZR*ZKT zhgefjb~9@e^~=ru)Fd}LIysAHfe(NJMb(D#?5yb$dicNjXyRLFP)oPiCDZ)6SVubph5&uc7y+ zXu+i?mC8+joHL@NOoL_bhEEnf3!x16*UkTYctRC!`ic7;?L1dE=!MX7%BKXgcbBju z)ID6T|zN88xRE<#cXc+?T?9wb&cN{k~VEL zanX*h6k2UX8^@t*{_?*qYC81~2eW`!rVYx_Aj${|wy7)+4Sf$Q*oIrroSc3UI zVdg-<4cj_*OJ_mI&X()%cY=zNW=yTmQN>|z3M#w)q_UH7Np>0~yrcb%9E_`b6R`Im|MtcwZ?#jQX=$Xi!#m&?XAWK2HBKkChg;Cu zgRnPq*>~Krgr3H+66{%)PQ<-uFUH1BX^VHJS0-Hb<>NUw^*p^G#ZVu&;n#@%S;pSpH^|!%67p32}R2y6)^Qh~}-`d)z9E-!Xx_$Ij=T^vd+dSRyi@ z$|TM1+wRaQN1;zv662=K=ipe`Gv2qJAs>}VBn}OGy|My#+dx4#+w2ro%vc-6Rc!dw z#Ca-_>?-TRO7s`q3{QV)J#-tB*{q73*cO!Q9i7Wz!Gr{KTj@=BRerrFuGfdKbI z=KS6OOD&QRHSZFNFWWsmVk66H?M*wXIyRQKg;>0}yRW@}7@tswPugW?ZS{(+w@rBM zL?Na3Re}$<{J#J5)o`CFvr3bcUBI5Q@vU)Vu|s2?X9bD8LA(s*}gpPRjLSJ0;wecP_ZQb7jKox6|lLFmwp zN)L_0Ufft|3FCp!j*)hM`8njrR29L?5y_W9Wci51bINHgHHw-uIrk$FZxxXYh#nAi z)SyM!I9l5AB@Y`6_n9IajXBShuems229jL9Hs_HyUn+1};7BV6t>2=#VNP>)A)@J_ z2hI^!{1daYiY<7}O4xQ?WkRE({8|gH*ngXSM45Ic^;KJAtHKmMgvoj3*hQAU*+ZIv zXBmeVKO>D}?QA!FOIkYQsu+vYaqZ{=_3-EA=?EUFM4R=duvo*C$zd&d%cdV5k^6zL z(Z&%-8+E>K5So1>whVUhr7InI6%&!~v-4|}PXek5`U4_}yd~lofk-pvJhT>(9gx$$ zxWgk4ot0qrIMyYf4Cg@cHSqN|kw{K5e_9&l(_t={+Ug=ii;WuVd=@q4MSuX=^p&TAG{Q{xZXA_0SM^q7W9>w1V;=L& zNX>>*X6M}*#DzWuips&DZcTwjX8LC{^16e(2?EMB5;Tt2$Kl!s)n?+LuP$QqvNW2U zH+Jb=om5f4RY|*VF0sF<|9TmkTFrAhEYUOz3v9;aZ8KY-X{j=Gk zs_>8Gxi2O5%4{LYnLd-h5c3^YifWh7t)Cr5L5Xsah~m*+RlyyTi#(qaZ^Msbgp_=A z6Wq*f^!3@9v<w2Td!*Gri; zd(Fpzk46`V~1)jKB1aw$st0aGRa>$sOiTR>PwTx>|7A}zyMH`@o>!%no_sw zE%HZSas9GQxt3Br?Oh$iy0C6R(<-^hTCv%f%wD*eS61?Q7m-S4jOQih$Tp4-zO?Hu zNoy~KAXI|gJhkCpKexHoY1RI+Oz56Kv`3iL4Yri*mjF4FcQ(U6yQ9AmvW7@N$Ui~$ zKN$b#7UTsiu^9@Y%~ie&|2p0EzlGOMfZ|MA4$EcjeJ?Cy$jD&0VQx=bwpLiBnw61@ z{O#}l2CiTH_er}tDS&)#;A9*A+b{0Jp2$KQJ1-31u<#!VzP$eKOuQz7!k@kUtO1Y06t=_sZ?E!)fAF|sO&DA^@3FxD)l;aKLi7c} zo^Q8MM7iG4ZNZX|WBw!(!n2>f!FSO|e9@7}9D}J3RAwgA9siQZ+vL6an}x%F`9Oc~ zGUEmTZwcI=jrVnb`@F=2I43{K4Y2iS;4Id=du%RBQonNf4%B@%l&u(_&J~d&;%F}D zbAH;neN7#FSYrq_L)g&Q3LG3gX|$jaxI0;cSt1oP!ylz|zaI=SE5LRxFM4R@{`VQ` z2U3*jOZj48dnow$SqDo=)n=_|#TBbJ@2~#xEd8r>4)v{NLdd938~76b_QT)b_P6Ep zUz;}AU;B)OdNzvgEB)c@{O_-rzKONrICz+j^S`{!KU)3&{%gJ)FyQzgii~^pCmrJd z`YD!0!8f{pcJ2Lz|J%Wf0>g;aE6Ul-kUt;Z|9k+)kzh%A`QY97oyz@xf1zcpC&=d} zO^*MY-^BmVPdnvPnLfwV-Ph|_ZS_w8VOGJ7dp#HVWe>3f(ND9NAAiSEwqszQ{^kt$ zCiDVs-RG;f*z8D4txf7DqA}?J*50E63vo^lfVHQc0r+<<9rE>+>(p*)!c7n{GluOr zJ|)#w0bgb6Zpu5Ir@7h1ZFC+;kR5!kEt-TyaW9U1w#jzB zLo1j5WhY!EwZwltPVNfMP~G;^6cPEvz65NIxPI-UtNhR9%qVjJLOW*`5BSglx`WEA z3t;y9>p+q4BsP-qG8_kuELJS*=IaV@>jUj3jjF9VarjrRbjENZ9KAhh8$x%iPXFi> zw4)QTEEeazryo8IcRB;GAGQc$m%7hSmn12IMtWxC(w~YOTE7Ojp*sdE3vYwz2uIF7 z74PP`#b@)=9UMhw>h-r70B&Xs>s`0q?yNb_tGI6%ZO{%J6kpfu#C;^*O;PilCG$UJ zzftw)t4#f(?^{O5O`vuf17tchqsx4YGwuEZ8BZ>@@aXwh40<$&+>?Y^kt9899U_o1q zwL_?x)_YS>KIJ|JtZ}GTuM0~@R{*5Fxa7-i&Z_<4#|ma8poL`N@#E9Odb3cL_~oy> ziiqYK;Mn1p@j0~7;l8<=9wM#0dg106@C;0CaXSZib2~5*=rsc^;~q5KcoTMiV7>qK zVQ=dB-n(G?*aJ16V=ZSaj0fxsT%OGdR`!?Dq4ZT&Q(HcG=mPqnq`c;b4b}d4@WnfC zdhMKh@1{%#ILoBPuwh$wKf#){ZYGuj8~ES*5O=~1sLtwVV^R$7!xBpvwA5c0Q(lW#L3wT9M|b%fn^}K z*NU>N{nU}f$vRnUnD9Bi#Fno$@Ma)RPy=R(>i~dU!#e`Hx@0i<`EVe*3DH~=1-9)P z<FB@$z?;OnH7_n#NEM{=Tc`r`H{oc4Y_%FK@xBL_7vG|=v%DK{mJbuj z9aE_I%>#JAVzN8*z7d|DA{#38)xExa7c4Pk;JT1WXnYlIRIsrg}B2tZXoE?BOG`p5O-DtBo?kJ4y1$ zQVVSpY!8%29~c}DB=DUxt1b)?Hd!WjN$(423_lc95^tATFN4&LR4?8dyKYs`UsAsd41X9C(+_(4_FgaP zg9_3gF9R#^V6rJVk$RG$l328R4opFE&WII$agBLJwUCnEMR6et#U-^>>(f7!f)47B zuE(D?K5>+gTB}PLix633QcJ*{HJG+7ONG7!+>Dw&d_!s6x8Bv* z@!FFAG0A$w_a;%EF7fC+Td;qgGQr@!E<a49`d zGscy2LX#_!n+8UJ-^xUaa>ly(>jTHTsbwpuJ?t%`rdIHdNRXhC>+6K%m4H^ z_T82>d;*oOa5lV1aG(#&j$w5}K5Jn%ySofxYi; z+OMOuu_TLBst}EHkp}2Pfjb3_W?!(QZXz6!7qop)VNBoCFuo#-CDE&7`s4E}1kyn1 zqo?Ro@XM`>2ISW*K;>(07HC}3eJV}E_x6fRR=qe1S+UC~M(M9(eVO@+!M@NYbO)d4 zD<*w?MvrMdY`@AwXFdq(r=I!-)GPt{;_!L~DTay}S)(EC@ge^lr;jR0ZX?zFYPKbN zMdT&{SKF@x)0^aPgoVw(6eA&Uy5f4eN|L(d`GLWicI+)Xfn-nO{mz+c%`Y!LGuQE zjszGI+^kr^=E)=KHJ-+Y?%9+$t+r>N%D>U}1SKQI94wZ4RkuZ&T)}uiF9j3gOiUG8 zxs1J;NGS>pWJ9HYrNLs0Z#O0qS`M^Fb6C0Lx`-qder15DuJ>-8gU=rvqX|#fl1Z0|ri1-l?Eq8k?+i{Gq+5_fVQ=%x&pSOmm_> z#6>gKLJ}P5uH-MWf{HB~m|!+Ewt36t(7l-@LM!CE0yDUdgP zZUz;`AllN#_jor*sQRr!a>)hy!+LzbI0kPD9RqhzI!~3f%0H?|EB^e%^2me?jEW6- zFGiPoiHCGJNNh@regx7-Uy`J#ZC{Co$EqbQ&OFP0Fo+Jx0Oi>|I!j{XS1*Y$>+uTC zw6O<%*%GUw0k46G6xsBYEWQ%Kdnj==`j6Sut`JUuN3EEc79CYenG}6jiR_DP z)#jbgBQ;!)tv-BmPw1J|+mwkQ3n?`hXK~IPFh?5OUi94q6(EsKyj$!`1$pQVR><}- z;-ZGn%j%zFr8a(&UL@0(yNMFHc_?!92R8?&B!Deb(9i3HTSIk%Ua(y#^2z{wtoy=D zyc~$9cSlBZ zc<~Qb3_i!(l#Q{XMoe*WY=Uc}U5d9`se_lY=wH_qMb(1J^^Vy$FjR*f@whY} z#><;=cKpbnQqN7-n^ze&G1JWfS$jQKS`iIK?*ip7jvx4VO7VCJvRIu*K~fZF3n z=S2U+xL(7}lupH9Z~C-9F{fDMF9m1$wSE^))S?NfddN0MRw<@{WQa6Ht5#fi`D z$)!EJ%WY$FQgx@mc~7EAlWSLEm;_;;Q!BkfmYBx3l=*8$hsp;;)BX^9>lqv823s(1 zy5M+2@G?(Z&bqv9$nL<`-y?{B>u(qq!4d@m5OBGkY_d)^xK2Klhz%y7E{I`|EH*DD zR1$(WzC_LV75labz$>b+jz7ldn>22z&{LyD13EhJHe68i=ze8TW0S5`O91`H!}8MA z*pA0E4WHyvop6+^&`g9Tv4Yz&2=qGEb)X%GLKGSAZp6Fewdr9{5ddsSm?F2z+vcm^ zWAVtM0DrzN<}y>Wk$f5*`!~0&w4q^s7x-cb_JiF67%x$1VTz)?>o4L5bg_S62+M9( zq<1E1kqEqo_$i->y<}*5GE-0Jg z*IJybc=JeRvrnOGsLPGFXrKMfbO6qErLPX_Iv^_@$0n~G9lo_ZClk3duiz8qxGDg& zMz2>k`7NZj{hR0LM~YH|KM9nqHs0c{=05HucZl+&>eURpqwN3r`AT-ec(0*T)b=ek zSM62ZRAq%&@5T7&^Hhvil36@e9L?}kRmrnGjgd9oEjfiLs?Ks$J;g#So2WWhD^uZH z^{5Y$uY$1t17XH4KwwvcWH6r{&kx1gs!F>lOpbmtr!aXLcQ*hbJKK?|Q*l=Z${0`{ zdC7McETc7+z8srZAw6;SnP?{sE%F0ClUkLLAbBtl#(5_+h~)GwHP;LZ*SejV|6 z1eHjgP97W`tpIH_Hp)BO1dpUK;44GMCNe8D{cz8~T>5!)PoSjcZoBZkZ_{8SyyTZJ zT-&vrra8Q|EM#rXdRDf7LHS*U@WPGM2n~6`b|&ybw3oyx80}i zPKjwwF`a#W@_=OaS?t-*-Sgt2cd71A?cHdCtp)_|X5JM?q?S*1*^wP9j$hu*zh8&n z@!Ad<@(j~ALd{E<&^WcvZ^SUlTgNA~;^0}kpGcq8e6Z*P8D-!R@)VjAaSc2`> zQpF$9oe@L2Q3$C4$<{Nt1Ax}XWg!M85lm$_h3v-TXMW^SE zbTE>NmZ92ZM@0ldgZX~nU56gduc$s_-=1c;&X`fC@uh8TM+8|B8Hw+yW{VQ85%%Ii zkkg^W3^j-XFN->L1j}z02Dlq?`Hp(I4rwR^X6a3Wt#j2I@$I2@^H-TDwGCrpA<@%^_enm# z_W&9U&qwR^0f$CF1k${*X%ZzqoDnHJ&Ogxtroin`B#ta3lf;xloVjg?%jqK}I};@0 zVa=x{+Ab4d+X1;qZntNLDF);fds*W`T9k_^{82TvkfQ$Lcz3YHOqv6cbboYl;%t-J zkYjVOjkNd9m&F42%U|z4$vT0O+hz`vGiK3O!Ww` zdx=!f0kZ+rHgERL{TH;e^Z23dtpQ>ee+>UT^HTF{Dt6jiaa?0>!*cNO@ z-CH_wMklw46-GSviS8fn0t@26U7(RB$|NQ|IOi~z`fSi+Qo4v!p3yC8}9o_Y`q1GmaCLhX5ap{0E2a}=(zO0=>NDWjsVoZ-9inD=vO2rlWAFnMb6 z4qWs?{&*>J`Dp8A{qFV34l=*-AzZ;AL!+jEbE;P_A{2)F6BSh}5n-dk&0+*rbTCiE zn`FOKhe^eW_}YDzpVYck6bMq;pVF?*$BHTRC{!x_+RbokXa$e+t4niD*lxs*Y7ceF zFm6{0IJxREVkwb6(a(+q@W(2ThKTg^tbiUxLGVn*^%zUDv4J1;WuOhWGw%D~W^70;wm zTP6<7YNIT@jRZtN-n^mm)QZU5Z)a|nmj|8TP>py-cB<-I*9yl}pTOsOE{Ae7HPAeZ ze5nexG9{l~ysML%3X4_pce!L4jMvBopa>v~f3Em+Lnh$~`#P#rRwUf&I=K+?FoorY z^Q)Zhpv-!6(xlsq6!Rfnh0aW=<1FU?@B)~AuCZ7xfO=mVxK1vOmRD(KFLQiVj6sDy zOa9r=$i!toej{wRbLnFOw#-ds+m3cA7~NjrxQ)+^R8!}!4+%7d8>*n>7Hb@%YJ)N< zJ_^5+z;;N^oj1j_;o0P(HT6Vl#%3{fZAT#L^Cf0t1Z`))u3;>MMjgF?1@&=fBwoK{ zDFb!Kii_6{)Puf%!No{K?*hAk$=(=~NDxcacL%vEw;*UUsAL#`@ZQ}p z>3SSa1*3Gh8N>GA7_pi_s`!L0yg_hd0FHPxS{)%Gu;0@hJJD=1Wwnu?XrlQrA~sPo z{PA_?0P+i!S2&m;W3PjXJehdY_Wbq{0~u=Tn&Hn4EoEM8$2%`mBi5-b%T&ZK$WGnd z43;2Nw#O{o3eklLV|XlPJM^OYR`6}Uv>iYQeFg$_LG!apr__GWu{x0k>xZ{r@U>s9 z`K*LqG^wlDsG*hVYH(j~Iq{5jIQ7dTEy>j(T>3gLhW54($rcmaQDO(Eh#l5c;I#bqHv}$N@Ak1 zpG{o|b5;4xywDl-{PWZpJ4apVWJ8@-n-FH_ITh5TB4e!&tIF}664o0s>Ms<3{c*xX zERG|q1~n$7Et$QuA)Q;VIu%V^E04wE9~p~8EH+j&Wg{ZP;9djn&f&M9v; zjgmk~GVaTeKrY9bXuf>2twbWVNs(u+cqAOk!K?h$7Ry=?4Ion6L!-G zv({+TW-v>w_-c;oPR=K336(R}60<{4&jX(G56@DFqIJdJnwq>Fbk06`w(!Gq&*-x- zcRrRXD}+kt?9Gj7!F7u$Uog@cVUp$b$>7nJ3hb?7z(7tS2Mm1;o8qhpEIgvTYrZ9~glenFjGAc4w`0P?ad&uY#V; z?@1ebsvaW%YezA@{ol?0CU@Ytl z<^!DVv)=8TrHXB2o@b(SXIMkaj#(iV$Q%1c0xDA;ZbLk2PG`}HuP9TuP?)Krn45qM z%%;2G*9(z&N5L`E7&@vD5&CA{l#k_>DcU%|FeOwNp0ke1CfSjzjos&mj{RIm`SM(4 zgfn*6us4%Aias^lF_9^tEay2#J)qh=xs`Hq^1O9tGUf_GGKsUNT5Mv-A9UXfmRP?m zp1U0#u1zs&JdJD>p^cwzkLk;qaY%S(l}X_Dm7VnVp!Y z;vs|A0par6;8lu68F$`yuP_Qv;C&w(S-{cN!2A7pPFd6Ov1n{%`e>`wDI)LA&i z7Df9W3hv-Ebk#ZJ>lH4U_K?UMWZ#%U8I^Xnn2nbf>E+gNmpPiC|*? zm8%>}#``Szp(hZ{T&Y7L_%N_wKCt3y5Yb^+18q{#U!$SnEa7>L#)wXyNR9?^qAGOm z+I&5Z zmyHvC6dhO|;#p6TRw?Hy%wMK@xy0)m@?^iKa13DW57)oi4H#jn6i2hws&*SavOd&; zsVGT;$pWbtJ$km(B;b%;2f?*nFqor4G;*Xs1#ko-n|i25O*4@yDtdE#M+#VIRBbV+ zy?r6|zC79i5a@{Gd4e#b36b><(r6kk&>7I^N7Tz4%Yr|3`i38I!~P4Nvt0?A`mOjt z+kkd5yU+*vw~=T2#9QQOvb(s7ZNPjglK#KlW~! zDZb&4uLk3io@FxoeG^A`>*etqjueVY7}xA)TIeSZ$!>LL6^X@9X0Am!yxIT3n<|?4 zAU5MbDXFN_ywK>dNAx{kLMGjdnfp+;RlF`N#2(xS!rPbnXPk&J}yl1x< zr+2)nR`6s{@FqaWcuDxIGMzuJVwC9TmteWhGWvI{eGg6NF58BOL&(WiLu6~rkP5~v zt`(GL`5EDCVf4Gz4|3Qmd5=I}wA*A?&>gjCvwD%xwN@x`iRVh+^nKF&l5Um;0k~vN zGsJ+Jg7SoNu;%a}G+Wg!u05u;5Ev61_JBu-qy8tlX3Ohiek#|edLGem?N`~RsAxw;t5TgHpr6t88erwct1pGa&HLzKDR zb5*+pO6<+fco9b1Q|m|zh4vXOxNU`$CbcZM(7H_I;};Zh^BcjeNaUSwMf2z21v7<3 zpPy&#=E&OKr1HdTP2>%+d)8v43{ZhTt8jwoec!S|W^5q|*P)&Ju9)}h`x*C|kRqbd zDLESPZE|%{R94OgOzhJPH;dTm(3Q-MjTf-2eufCz9hvNgmc}%4tL8sVbrAdI;eT0h znbfBmXSnKYX9oR6gE;ykdz$qJfwtiu8i@h=C;| z*M;WrAyH!oihQmitBsQHR8`^#DmcG}b8(-dFjNlPix(*pefD=&A4&itYn90Zgyx$5 z;L;YM$P_JHtSxAFWDNAAgtlDP{bRHCSV*5i zwM--SSU|;c>EpFGbH73oTzS9W8n1xDI5(%lRRFi?vi+~~p=Kg(^jtqyMhmCOQytnIRSAZ<>b|DChwT0?vZfGM8h5*e$bwezCv=Hb`7V zMXB0;@90Qg#YR( zn{F^=XujOE+K~jk^6LtU@RrtL6fa%yTzOgyypnjAS!vR7p~p?rt+d8G3pYwuKiOu@ z_a*-77f0gV(TJ$H!6*)1hkB^A+h*7C^;Q`>hc7YAMfY%DX6D}ifbVcTCyiCmYb^JZ3u-Lx(68p$i|sebk#*8}Dh{@rU`W(2j!)&0(LwCzXxc!G=L#@vwz*l!-) zo@-iqGjZ%&w@Dz4X)&Y!@}=gMc9r(?(jGQRCrNbMyLEvQzRtT=dE+%qHb_kn!GY$1fcrL>ynBJn(lb+o1Eetizo5suehJ zQ0TsSK=An1Jy6!KWH!q)1RMS!$_^qGG#59B*S=#gDzNl>m0fC<1uJ2meFs04Q?8u>S)g12lnu@lqyxZ_YvWvzxui!5kGU zqAMWJ!#(O*coF*rhGhhd(OyS@ZuZ;l7Un{(whRn21n~6{_^r8OARfGJRWYS(rOVK< zW5TXZyy=+^S?&Nn@42jif+kl^g>AF^7n{kg;UP*{289-5Cw}elwwXgcc`7yG%{!z* zw^Zsdiur|<4DXe^o;)Tg*l^>s&~dAAimU+mlD-lB<6X>wd|f0<8>*l#oIrZ2GnN#0 zw&H=$L$H?J|Gt*Z)i&Pm)|mHJ0Z|WRfUgcy-||{G-utV61&xY#ISb^0Ws2KEp5_5! zE+ud<@wL?ofbG{mPxF{M2)hqNf>kYM2YibL+}f}fa?ma}Oa40ZDHQXaafW-TkMGl` zMG%1bDhZrBqFGh!cnyVm*U%XcmFDF;zWK+}NSKsTNd{*7Qhgl3-lYC~fmA22TKk?H zbq6kmID+M;|)*n?yQ z)EcORxle$3EO{kdP`lHki@`149a$*pj=m##)S-rxR6lL!82Ny{6Bs3BS-*S7c1xP8 z9#0hsQ?OU_fBpfQ_ZZs=+unU}rc!sIAGa81pV({?J0;HRmld`z#WMm{BMpBm$60>a z^R{hxGZ*$c+z)xVhs%C$uM!~Qt|-I7;!WeC?p@9R>Bk>XctKz)g?#w@F4S*XHbt%w zpf&42az%%i&vy0xn3vcMUjkR(&p{cUz=p3KFhVt{fpDm6v|Jp9Vu_h=HF;vNsvs0j$HR~!*%ZzAULQ$zvQI+Ju{3z*{?N&o{IxxcW zoAp3mT-WNwti58d=bIIx!Tvy_bFfziCm_IzaJQQFT)Pk>JT8jXyXYhWKXQJpkZ~Pt zk@DX1I!9*Yt4f;*0c1EVfU_7>;@w;$w;%nI5#`N?JK&_MCqHEiI2bNQ(yAq9PIbQ# z6;iz&y<9QM0$j{QyGx!3k&ow{)t%fNL-iaGPfKB3i9V%*6mT(QJtN0KxWnz-=~QdEDS0c-<~F5G zY*@>yMnC(nDwfu>FV-UrFORlM&opZN!qyY*nHi2iH~QbVp-t%eK2pCN)i=o)emY-B zKt?3qoS@(4Ps_oaK;|OyCScBoE?@M=qeQmGR^rspb_0JR93+hCCorEU9d@XmNE%Y@ z28??RZXGATUBgFq?qSQAMAM%wD>S@$c-X%~55aT( zkQ|u*7wE3z#jT~JAvQ(3pG9P;Nisex-Wici0Dq)1`qh%~X%(EF!99JRKVJk{CbRgI z3ET4S{X651dy54|R@Cj^H&sOV!0Dy1YA?5EjO4~vUWh?>FlDpqpiwk1bx56le3B6* zMHTwo2%NRpjuLG{ImMlV3~R(>I)V0L!THil_!<8LjncQO4R6za3V=>|s zo>rdL;v7v-_pYSm-*4qptmf6Zb!F4%VaMP|6{XNX9^X0X7c#%q>W8z850DVj!oSui zVZ4d8K0VoR*g)ZgpqAx>Tzv1T3k0ut2Z^W!6NL~CbxSv81}@Fnq3jw4M=nqHX)VEO zT?zEy%?=73m`ka4@311_XKZuIu; zJ^M^@?n^o}v6Aecg&Eh$ZSq{p1=x1_$+YTW2~vw!^50KN=v&i<=mo7)%8MjD3O{gI zn-2~26U&FdSVz6kr5dx%z3bm%o5Pgr6luB1xjqePjsX_+D%;43jd@k&3l7g%1Dn9x zT^X$C;dUV3nSkJioFUiTP5vciw@fv4^?Y}JN$T*>Os1pMe^h!$BbS^dhk#{cOq|QQR<`X%_jZy zjZ{kXtKN@iv$MZ#nmh)h1w?FSwF0*u%ulg5hSOv=?BR=Mn}!6lzFvnmb1rGjs5HF+ zGqA+@8;s6rgI%s3&7A<4{g&`upF7I>)l>8yEqhY5SoZDh#CM~fmsr*S5<)whzQt?i zQ&9vU((hPQyeb zq2(t-%V{MW?IL`y~;yJ~s(?xs|l#!0l3xoA2rd?*{w zTaxHZXo(U@Wj9@Ddqq>I@)E^sYjg^z%G52+!V2SFoXgYd-ETGLql%9{_ExRB=?zGy zxPR~Hm1j;l6mhU?_r$roZ)J_*ZM3(>>t1vPzRvSz%vxU<=(7YdvHOnAA0ClF*IImh zwtJp~5SPP=z@-N}>&B;>g~O*(;_pnvjC}T=s6bAA$5s;!rl0`B2RyrrB@W4iN_- z8P{jLig_$I8aqSy88icZRMvc1zGZFb=tepLITyZRqp^TYode+513~`98S&->QNYE3 z+YSJ*5A-$%uC>IXEg#NfIoM8jUunPOn`(cY_{Bma?b|t;yVh0E>v*q7c0847p9_Nm zK?5383qlaYvD|8TOMWJMdI6b+D{HJE!}+nAZ;yZK79zf;{tKF| zV00h!GLfsBAU%#jhn#esGUtj6``l~Z%OYocgC(2Z2URnkuwO=I7ZRd|#M{I7p6Ra{X$y_s!e%MX0(&H@IY|oCx?hX%SBx`Cl6SG^-ebMa z5b|sW3#fbS?kDkzEz~xpw!}XEDatxOx_^iYhCxIKc1V@1b)!cojXPx;MND=<_ScZI zggj*5%kirlj6jAMt55Z?+`Y1bhP6_M&_TVf+J0qC5nzFQvP=oub|v*|?~Q3ndOT2; zB#N!de0i+TL<6KIiIZJ%s2n4K(w(=IJGOet%MLUu56@BZRCY5|js+^}3Q5z6@(hY> zDEmxtx5iH`HjV|#w?Qc`#t>^qpE?0e_N3UF1R1EyI>+;4)z<{cH-hQCU6<8dgS$z) zpX!9|nVc!E(pds~0{YYM_zlO1k$YmiFC7AonCHDAYa-PK#}tdCu-=_4Rq-uL9t(jC z<~fp4Dvl7llnj+yf4i8gQ(v?lh(!5}^GqK8Ga%;NQa}8C~ z`_IM-jM$0b?6Sv2M(YFwP$!d~ZJVVUaPFgBn@3lMY7nN?jA4WDBf zKKazF$!Bl7&H4-PA+}xRpXnTCoRmdBCU4znDGXGv@K|6*H~az^f(JUdS+@U$JQZAS zLTS+5@JKM%1D;?)E7J!gk5`fUM~7}p9A>9u7HMzZ_O-{1e!hQ0_tX}QiP&Qz)?X>1 zt=Og1o!d6A${Y6=Y7_)ZYkwEP*U9?_Z%!`lEntQ_3V8F5D(2C@^X9y0??%OdTqwu0 zhc_nWYR#2cIGLQtGYZ>c9Zo?#Q-Y|;Wc#_twfNeVQ!R;+?X|7PzALD$Q{9(tK2YlX%+;j1aC6oF z)Yh}p+$@WRZi2iah=-MTj3ApoE1fSC6NcH_uKC31BQ6mfi%TFJpJPJmNVo_$^Bah9 za4EhEcGbe*(rC7mfj%9_2MEU_Upc!Ujpb&Yep#z@j)bjvB|jC!7RP^{bR=2=8I4Lo zV3%m?umFO9e^{DN@a;6SkCvdUZlLaD_UK{sNL}{MQ!SEF(+eV9siAvbXo~v=3<|6?^$RX_DtQ9(i@5WD;)a>O9v>uk@FK;*8d4oc*x{au+ z?1rB_|Hb8*!jVt!O$Sf7m70*QazGGFtp%S% zr&YH6G~0G!^rjEkH8rJ=a#Z9mqe8Mm9O(1gJc*LC#wkY1roBCg21MQO&Dm}0r!NPn z2%|(Y>u${)!KTxSaw9e^$y!Nu*WEosmgZpE;JJQJ;g2i2DFVBq?c@9wUfgJG>;p+D zH865BdL=11*Ib~ZG7+Z;Du{jJn#BzMe!Hy(ygd&J=P2qUBT~AFG(VKLfVbGzo&7A* zN_6B_*)?^cM(?^YQ^S3A48uQ^99B=f&ivl&<MSG>Zw&ZzmA|SI)`II?ug(|C*IZ}*21K5Bh902X zNEaAQ<}H0aI;`Pos|eVXpCHtrU(&<=Ip*2=R-$+J=)j4<>jl}UIx}P1Xg-`o8A&q z-s_QUNWbjl-7lhc_lHaQ$6cQ4GG0Eu24ypU+sAf68s9IpGJ5;K8|XEI4u0I!AdS4; z66wr6k9#*v!V4SdYFZQPbR~9(or82WqdZ_?Q2EUX?T&NZdy*aCWVb+G%U;y=H>kYN z-~kJdN5akKg>Tzew2HwS2D_=Rio@tK;10X9WH}wE`*HL4BuR#2RIr?OAB7<)+U5Sk z@lq8g$U={YmYL-sK5v`hX~>#kKTQlDEb8aN3H_`m5WEru&>U+PT;v$0Uhq_X^UPI_@|a2S3&(7ZTINP&S*fzrf2L5*uGt3p14)k0+VVSyXLb>JTxtZQt0YNxglsKVC*NXor9B24TIIdSs0l#$ARCA3;2{|k?WGMYuljPz( zGub|4jo6k?n8s*5M|Mqz4-FaoN8do1b2rechV4B(xmstd)hX%W@M24=csGdqOs!d# z=O&y__t>lvLUz9+Z+5c`UveTBPyA}p5RIqR{#?w$`mVWH*Zuer*RM6j&&a=77|{w@ z#>NFMrk&2Sb>J;!2&_(;yiNJ6RFm10J{0u&#oFiMvb(ySVZ|?xUx?lrPs;iQ-LgeA zWkBCfH$9suB5-nE9m;LcV^DfXKE5Z-&eN)C?}$4?NF;#q=q~n5Qh-t55u4H-tDNUS zy>K^Uy(tdVtFyUVg1ns|;h_SnIe78stU1g2a z^g}zfFK3M0u$jKydhcykO!3a#piyK!4Y6`TH_7rl^rE$dZH!|oktU-fdu>M%S8KWM zn2U%P-5TFs4;YhBCrhLg1cfpEp(`U}Sj>o!=`;1(b+`TPP8`mQ!vQ2p$X>}`r&5tp zciRfj`0!9MV&n?{ROV4yL+rHnB(J=kNU&LYeG3 z;96Pt)-+~*YZ38OKuZ}Qm=kZ{N&3ZJD=7Om1WmJ8Yh$mW(eY2oyssei`_2KVeF+Ll zK@J@Kt41#d+ceCn|6{SqoO6J2U4MCb_iBOXjJeg9*CGrw+RuxwG&{}v5plI67?po} zw}0zS@epGXb7DJ<-rs)gOAsPOEz^h9Y|n<;Un>XMe`dhH<$K85@xQ?Pdt8uHng-n) zv)WzrSSAys-d#Nz6HR{1Q)Ot|x-L{$ZPA@*B)%Q_XUF)a#b~D)+C^e&dKvB=nc_EHWLkjyUfBmiDGE5jW|4XiEqZm-Q z#lilQxNn!RRF*Rc{v2N*1v#{nyeK+d&+QfkU5i3&Y9AI$Pa+M!1L-6A=ga&T0GyZ| zOQbKOwJ`rY2Y(&-zYdp2hZJRvX( zXmCO;v=ja(4Bz*n44}*}y6fD?`_rxKzr5)`KJ>qimK_VJ^94LN_)m-Of8@W3Vc3t^ z*QKfPpFZY)f5+bzH86Q&gv2pqo&1k)@|*DcU&jqz#Sna%3{PI@$A{hc`?>tL|NdY9 z(TOGGi5b38;QU|S9yb7Md$3o0|Nehk1OMr`|2i;rtZRZ9bfxzHbk-;lfV{b$o{*hE z_`jav-@fmEtUg~pEVv`9c<_HZYxNfxz{Pi=26^ql|7p?0GVlxqq%VmriHu>B*4L+h zox4z7`J5*8-BQitQQ6*rvIUk@F9Bxb2BEN)gb)LYlQK7W!V~KaLANg!;%ho%fXg~v z$I?ZkFbemw}vR<(r@G zgMhLqGZ3IX{giFDk)oF8`=jy?LtozeLx*%yF&&Q1(KWByrC%Hi>D%zXgOVNZfH>2b zn%Qd~+p%6r>+2=Gw)eGmbe6kx%@%If>uG91!j8AtzW(ZiU3GaYqy& z(3vwQSfJ|wVj1HEEb*4!L8zK%2J`bz6MDn`PepDF$;lZO0~oCLZdK;l_6HZ>sbTH| zw34i|RaGJ-&OOze_u3Lb45z$_@xEuCHaHThg>(54EAfFZ%#e9J;u6GXLd>4Z&d7%VLg{`*wk1ev-8n5iQ)I$H1F}X zjT8{+=Y|j(ve`B?2tu|7{i{xG9kaM~EVRu|X63gxD4!wm8Tw|X#sx^j%|PzhdI-L$ zepm7iZ~~F|o8bro(n|e1AWtTdq5eJ*2*H0x<3HZ`=<#h$=21ZT;e)v=Pb&a?Cp-Z% zS4zHWKfdV?G2S_-`z3zzPZFMmc5=N8Om|n z01c)wz*5@D-=o_E0K$3`{ki>)ehM1g@%769@)2}=Q5D9#0qV$gkgDEk3yfDa?tZM^ z=^WGd_;Jl%y1y-hw6PK4@DcF*>F>S4w|}R_#@*L*YA3$j6bMJ$)B(2h3uNhd>mgco2I^7X5F@FcN0o-nZHbD6X zwh`Ae^|P$pYzSB467l1yX)Lw3_1~cr;MZdIl1WUVf7-}D7Ny%(%IKZyYH9{ED@z054~&Ljk`7n z7VVWOS>C~tujL7PXLE>i?q!!Ffxw3)Vb zD=Kz`N{uhekj`k!}P;LPEMFrIGF!kQjz;7*a}L=#uX4 zZWNs%BqfIyBnFTMVFbV7dEf6p`2GtI;lyk2d*5r_v8*sQ2rd7-i9yXZ1PkS$Ph^#N zkvslmo$}%SSdi4NbtmtFmSGBsF0A(q*H5BEKFCl!t}U=LE4&k&N0$|JwLO}bCdnfV ztH$T+$VNBy;rF9?f7Ee-p4kre?3hty%ZvVl|03;GjU{BFTk(Qla`Rm`(Vug7uTWgW z?pnOZ&4y`@!Ut`u@H7g}&5r{NjVw34`%tUL6Awe_qqCilai6ch%~t8k7d`+LP}BBj z&uu6cG5$6#s=dAK`OewHljTWh<_m^BVJUcs;W*P-9bsEzeMU>sIeEdi`xM;%&Y$cB zn%sMhu6rt7q^M?ZrJ)U0dIk^9Y}mCjw|>9aEA%`UKOR*(-pJ0c{;uQq;~y<8_viB+ z{X&vY4x-T7bpdIjh|bz0+9!XA<#yfB+gKd>uO9%F5pRe*Laac!dx>lJR~^2gu&*lN zZH6&Yvmvi$`_M`2bxRs=>F%ILXB2F$CqK+1gaB3JuW3zmn^7 zb0hyvr3w1fBZV6HsAzaq?o};Z6&S8uHzeLh@?3 zI&rrRVF6&^%?RI*oFM6vc@V}Dcv#_JTH`gt*E`hrJWD`G4av$!BFA;FnTFH1OUU2fC zl6hDs3ml^@-|t`r+i$Uvyg?OiPnlmtqEQ$mLGdj{mRot+_RC7F^bfW4E~4jazWwKU zZ7N6q`zY$M?aD?eH-VecaZ6jHNmAxv(riEFoK&>pj%;R7m;9<%T#c`u5ioTjhL_It zIUvh;UcGKhuZ~gwOW)17D+7Od=})UR-nRXs5d_hGiS~QlVYHb0ZYeT>E}G6m<;>!T zWNK`}`M2(MMel>ph4a_$!H@e68!JCNq2KIbB>Y*V$nIC8#^ zTsk5ET>K=QeN-Zh8%GI0N1wVRN)ES6=EQpoU3{SEgRV?@JjC_r;OoUVKU3M0&Ovso z50{?8>l7n=*VBrVQcK6yo8uSmMnOZ(kn3<2C6z!CldLY(v8=LYUzd8CG zQ@zFMkba?hvpD(`H-P-rS~1L)75fgI=p^WA3`T#TaGP;P?H8}{%X69}pZ1UeSuVmU zdzn5k-b^)ZW-CH&|A%XC$;?+MSbJart0`AZnTNlq8hUTu{NnS^(>$?m6i`0WwWlP7+bFI>E!4beC()WJzxFFfdT2By3zB<7hB^+Zz@YI zzXpUF_Z9|ESrRvg_K^ko;l1y|OIbtbhi3v?JtxFCTFtq?8Tq-qS+ikY?(nbAIZHTa zd2lr-N~#!gL5ifq6I7p{Fqip^5%Krrr(kKZf_}k?0(3|S8qtYCL;^~ zR4DA{j&3Zxk6o%8`$^Bf)2`niJkNQ?5^F_>Yqt4USf=0>O{os9OO zjYdU$az&tZz97REvP4(S`Q%1}cs{K!DQ&)8^IqVni$D24dkCx{ahIfLR`l?B;+qNa zqt+j9s8DEBQ7b7>=Bqt7*Xv*)a)KN$NQnJC84?t>X6}3vD?$-Pi}P>MZ*WKSXF6v& zVM=yV2l_#ObVK@>knPRXKj5*&VB2zacj$m)_A}d;#rQCOGR3WT7%yoI#mt`Yyh>8C zr92MDwlT)W!+i6LT=++F z7JjXwP*-c7l9OwoK2V*%F>w5$apLc87K++Fhu!0oCa4D3jAEJc&%k?tm1ug2AiTID z#mO%G=K(pD8hhytLBBrlg&j#)+ETCfgZ@cPguS{geuan?h!kgX$W^{`+LhfMT=LlX z0tlDBkeBhozeK^%yhYg5#NGL6x4X0a-u=qt-OBR{aMM?^p+!>C?{)y}CCuGNT7F+9 zkTp#CL(i@7pmkKrqK(cFGECw|1U?4-;slqff8O`j&`cgX1KFt(w8=y7mtLZf032kA zLU7St^0(H=_NdCy+Riph8L55I4P;0>txdq4k(RmcaQ)gO+Frq9pXi#P;9FJG+vYDz zS}M(M#BRb|$P0L3ODj!WQrB8WUkeU2MIM@>-hYYhc>Ycuz*a{#oe_8yp|pMlUKhTo zDqWE7NA(N@x-oz_bf`Q-#`r@EMS3?MODr#ZXNGAn|712bZ{vDiSJ^7W^h7#6z88Nx zua_3H5~J3!3J1H3WF=j{)xUrA10=MN?W|_!k(oiq@4%HU24BnxOg#zmQ=ZF1_z>M* zXyK4#H@=oLbsXY)U^9v~iU$j0I_$?Z71J<*vT0=OOT#>~{D$dUXcgmdkM8auzZl;F zfADvKK#MU^(z_z8@Ql+snZ8`CO!WE?GI>srB+6wkxvX5w0p)pJMHnJV_fSZgs?;?v zj1`0p_eL;@$|+=v#XE>+~a+ z`jzpp^LPdV9rLjAu%gA>tO0V-7MEx-bp5R!d0|miI;C&t5Pg)s+*yg)$Cz+lEG7rP zX{mpOy)6keNwE&p%*i$%Iuv9{MAn1O-F{oHc8@Atx5%Jcl*9Nr9*vIl3pC&|?e+8@ z^hCYjUBj|sC6lYhti3JP&Hg&h#>5Sk@iN#Tki~@YDK$?1{XQvKRjtB`kr2;${)L`> zG|EA;cq3Q%2!scB2$WT1;r{@Tt8U_7*rhx6pA5_gd_zo)?-L>>?So+UxuH8L$A*wO zbV+*D-!)3eNbvVq7~895tuh|=h5$1x{OY?J9KdK#djtiY;h41Rt{R5iEeTKnk2YJI zwc^`N6doLajd6gHu-^g1b@~Nh$1!`+oO7FJ-~7@j5O26^^~gm8z^<@^sT6csV=1-t zyNs-;ECt^fLt_5mNR0IPR0+>&C@}ppqodbu@#RDdi(TJE*(-4B;{ioT3nEJr?~Il8 zf#w}MbNR-E;xL{iMH9(NbjRGLFb_rpm(L)7qC@@^mH?P&A^=)*hPINoYG1|^(?Q;z zEP;J!*3F2lTH7v^X8M%7=W6g_L0Bqdp$K%1?kVsIowuD6f&378Txdw<{@bfU;xSnV z;2CR)lSSya4s<7NwC`>H@lAff!M2&@pR-s0&Nf#utlDBToR_^5(U17H2g_QXnUPzw zD|hwMta~W8mR=y{f?06rD%V~TVgE7;GZ19KbZzX`)bo>=fk$(F>4Oa6%mUW>8WYl zm{fu!I&1wg9%s*e0Rg8`y1KKvaZJv>RSk*B!IOjQDfcqIw98%4)3l~?bqKXg0!Fdb z?NLvPaeXoyY?$*5>WB45xqee{iKUs0nwe&XDkk{lK-Ga?$V1=G&5g^B)V5$!=UKd# zZE;Mo*J;)98)%H={vEeda;7+K#PAQ!v;5HuP21<8jUHhi5~@%DIYg(xgZzKYZ8SBJ zF6PT^gdi?ov!3w2-1mzm`1kPFQGM$@0sVsd z^#>qCn6&(wBE@vbp4MDBpZ?Cft_sT}dXj#jmv}dt95X&?Z($R8SQC&*3Z}4s{W$*R zGBcKY$`ch1b&^bh!@20$Pg;U-FR9%@az;x5P){y)@JaeA6i?C$J;YMP=&`uMFLWol zr~90_!WmWaPTJn(G1B5;RLXt!{O3Xap#Z;o< z_a>%O&;HK%PiOeJ#b4zIG>Gx0<8cD3hzJrhkk5C{jlIg8r{6+A%?RIm1y=`eC15yz zKig46eMjPiDoM3>CyjtZ0&)F>=?)zBY(26TW`AbZ3JF+47vWrurf^O0bheo{@aOQJ zKb1LsD(~epfI&9Jk0A$w4YYcDFA4Cfgb=Ad4+>~~u?mcH0g3I<1wFffKr{-T;p8&R z@xtDAmjPY;yz;j(j7yKQm0P+T0(Yvsu!T84EJXh~=&C@Ey&SjUp6x9VFN*c&=lsp- zYqHk`HGy`TztK6AX^kbE{R8IdypX-<;1@E{TNKoN7daaxH~H>gdJ7W9B{7mdtu(1A z?o3d$&F@{m>``J+v6tfttf0sJ4g@EbCGK0oSb$4u2@1Dmf=TtcU5$9rs`i2i#VZtmDdeuIfzP(0z1Ne#|lJ$T5O$-4>~cLgeYG91kusuj_XCUC&e zo|?s3N|7JTEA@kF{EX$_Zi)$s}lNbzfZmGdI_ny^Px6C@+sxt?uVv2Vv6qVsSu1n4XK<1AYl;FQ-Q#Vm14u%$EWT zUARWp644pe-*cYG~P_QGP70L_sZyUS{?Gq}WQjSA6C2HV{`mK41UX-g&ps z&Moo0UHXR-W$pEM?^cmxNhP3o-tM;8{J%`V)Q&hzw?W8G=@qkb$OZbJZB6F(zGzt{ zjm&BJ9n{N8Tx!a(hGZcqGq*$BP6HHd{3|)|=ncUFm0N`uR+NPfEu8K&chS}nl@G(# zB-m)Kx~@IbpnvHop%sYu;qp|jU3x3XQNrQJ0@)xHTb?DmL)DlPZ0|Kmis|e*>)Nr8uUyvx9^J};Xp zNliq}IpT)D@DVizKA)0vXso!wv1yRP+FlP2hCRT(1c(_vA0Og)0H%irAJbC8@+|}E zT-LFBK|xt8KG+^l3*f`K@pV0`4F2O;vr)pmusG;l!o;uk*J;F% zd7}o9GpK}82i8peI{d#~0Nd%ln*E*_a_p6K_@U^dHy@;o7wRQbUD?|vOo2_HIi>)E zBZICA4K!afXcKsy@sf)*msIAA?+t=-7IoF%d<67WO)DxuO0VR3R`m%3pG2=d0Jaa!6(9ByL zN^OtvSzvRXSTr>aPa%!pnqW}xSB@_Z&1t_E1+EgL4yVcX-M;_rBRJn+yzg9;yjH+W zYSdipO&(ClPc^#X?B|6LG>{UPhQ9o9sa7+{5}me$B(%jTK|5(rr&YxOj8;R#PtGK) zR%eCQ#Ro2x!BNaP7|$H>Zk1q>lBTFu(9Fyasft8E_?{qnD+h>DDcDg`SQ5_8(rD#m4d+9fWqUl-eFFEzTuZL8|G)E zd#rjG1>5))Ny3vl^sBuS9Vwc#WU@8A94Cd%kmVrVNob$_96d|PC6}F(L=46(f%bKU z1Fx*j%e&kIqDI(<-U>0MJ6Tf^Tug16Xs#)l8yCnVn}EvQ1^WDj1x<+w=IsonHsQ;P zu`0ADcwB^y1k>z_hkJ%8$0Ws7)(%1FUnW%@C%n-!^J>oo-~%sZJ7QV}zOd~3E_OPB z8MlY$y=H#UW$;^Zo<`;bO02k>Z0Xg`&!NS$e1pa;P0sxwku6MWcAISBaKxTSeO)@< z%>3!3QR(BG`wKNNe#oU`5WYK9i4qpKmo^hiWe`gor3KWxWV^oJw*XW@$6srGx zG+i?%sI)SxUgM?d6s-I9zyG_;LUJ`{H{Qox4T47qzk(2`MCM0aXl*@{RyLfqFp)qa z_^%~Lu>Pu)O6+hb>+zz0&d4k(H%D_C^DyvdZQ*cpGo9)T72z7}n zLlR==RML+oLQ`rebaV9qc93VhxYjT0H=km6sAR`%si@evaA(K2GdxWg`7xf#foe+p zs)H|Yi_q-3-7rsNRG4~K+eGyb+t~-7FlBaap-=Mh@xNn-H;t;qC)z4%x$7rI8%C?p z7b~=`O)E-3=^w;|{A=`3nU9k7uhSK7hCkB5OjU0c3xz_Ntp7l;p3{MI|0RdjcSJ2w z39c+FKkOK_h{D=;$AmNLwqE$prXTB0ZZCmuD#zK(WTfZ!M3Lh+V{$Iv4w*j|Wj2Kn zkbcdoI8Jeei85PZ(Y1#!+6t$Bqaaln;2V*rJF$%(5&bz>$u#O?R!J-aq@dO=kZnttl=2CaSwEtpH6Dn3A5MqqqX_Ha(fdV{U6C8Au2FkQeLV73 zvx7-1Y4}mQ;AeTDHerUH_5ut->ET0QWcZlvY;`xyBjh7-g5u07x~G|;ze617tDd3X zD;(@OX-oQdy57Be3tREm`L}{yCM8zM>w_PR6WYsMXC}9|Or3IALg$)i!=|%<`nFBl zLeH09%AzKXv=X~%(Uk&&iysn;s+s)r*Hbv)QPl7xGWSZ|bL3rfAFw><4=jfF=<_pAh|BnBkIeZC8t2egkR%E=!6*-%yLGql-l4 z1_4-Wy(p3C7UOP&>LcDCc&Mdg-O3NVeZ8X$3cs@%(=g6aCEKE#WAbsxBr;7P|BrA# z?N5hdJkI5w)M(~~`B5w|2Vo=3>qU1u|GNTWD)wJHMgx;GV&C4as+sStx#xpYZ7p(* zkTL5BkV71pT?g4(rw~w=T$hmEeOZSEXuQL6 zbndyMC^SCf)7aj(Ah3|zkc@Y^nlW0~CjJ)XGGbpRrO3$6vEGG8>NOvbR%)C5xanYab9tHWQhcm*dVc5`Bn-qZL>?P)%$M^{A2C()14Mh*rK#uo0!nXCG|aZf!deehr+(kOP|SFZ^FflVlnmwDu@S@5c5L@`$?0 z)h73e5>0)J{;lI*#BNxv&PfeNs$ayH#;k5Oo(SP3#k?Ur42Qju8|WytM1@qUYvDEm zKG=Sv*&r)1X`erdm&-e&z*%T()6*GwgmQZFf38P6xVeqO`Q!446S@>m=RQH5$ii+ZQyu7cj$Ztk9J~WM;J!KnK|`7VNbh z>+Cw@mxU80w%rTw(temRdJ1gkmvmPbQSE3oarJG%6;bHN5wjT2k1270WzkbE zyJn<`Q2D(5#MTXsEa?fw8b53<_M{0z&bHwZ#V2Hh(BVCQagSM@s}U;Ytu0P9cb)E8 z_0wO%iQ;c*1zq&*T?p}K62mZlO-|WIs+S=d?ZbqoC>^&FS(Y@8|`t1Bja5^lLC7eXGH4yCo*iOZ(ySi4Rdo-Kw!%= zr`X>`M6wOqFdxVa66Nu6 z3O6hNQ6+K0kO?hY>IWYNkB9vBpW}R56!|0i9FHxWs*h2nEyENe52$kjI6tM)=V)8^ zpXBGjBqi&^m^=Fm9a{2VW;bUY+7q=+_E!Y}f48C62-hWOY5yAhP^sy@my~aFN0B7XSrh4wn04OBrBUAi|T(JE^GaE+M{%h8Su-D z1N72yi(;fTt`|*xiaG3Ev{@?wUr<-ZUV33BrlYmTudjQ<&AC)43x-LLlfRKo%fSIw zZM|aW#kYU|7a2118`AMiZKq7~N+BKFg2Fx>lhC{TEoW%()F{=BzA?W~NbPsA1*d*{ zwutt@6)vG9p|+Eh8y{+a04Df($;B*>Ruv)1k*e*Anba z?2VsAcB271zjRoRb{Nab39gNQfC9}2D3AjO4$5M@sNfA9KD~=?8<*TQx_k{x;&=Qw zahpR2;ua^Ao%#;|T#VC?+Nyu2;A|ez`6Erc`n4Do)=yTl%*6xhP=B>BR&V+;pw^Uh zTs`69g09zQuyLmW4cc}i!F%*oH$)7`c+O>s6@VEIwKqqNvg=LvM>nRUmba$xV2|*>7mho`)xI1alrqaYJw_+NXwv8gSw@*`s1`D2 zpW>|O&=;_Cdp}z~*{J&m$fnu}Gd_WqkYPNNdRS=K;8Z@__on@@`C00px|yPm8y)L% zi;>;LRJo+8M-Sqicq;Q{yc{%wwvs^0b6izdDGeA}2SXwe{~^V$sjZsaFDG5&BIGq) zHStHz=(mpz_i6bz9F#{X⪙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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 073/167] 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 074/167] 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 075/167] 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 076/167] 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 077/167] 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 078/167] 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 079/167] 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 080/167] 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 081/167] 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 082/167] 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 083/167] [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 084/167] [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 085/167] 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 086/167] 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 087/167] 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 088/167] 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 089/167] 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 090/167] 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 091/167] 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 092/167] 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 093/167] 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 094/167] 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 095/167] #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 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 Date: Tue, 1 Apr 2025 23:50:36 -0400 Subject: [PATCH 096/167] 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 097/167] 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 098/167] 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 099/167] 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 100/167] 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 101/167] 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 102/167] 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 103/167] 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 104/167] 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 105/167] 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 106/167] 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 107/167] 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 108/167] 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 109/167] 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 110/167] 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 111/167] 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 112/167] 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 113/167] 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 114/167] 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 115/167] 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 116/167] 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 117/167] 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 118/167] 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 119/167] 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 120/167] 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 121/167] 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 122/167] 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 123/167] 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 124/167] 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 125/167] 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 126/167] 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 127/167] 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 128/167] 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 129/167] 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 130/167] 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 131/167] 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 132/167] 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 133/167] 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 134/167] 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 135/167] 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 136/167] 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 137/167] 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 138/167] 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 139/167] 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 140/167] 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 141/167] 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 142/167] 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 143/167] 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 144/167] 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 145/167] 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 146/167] 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 147/167] 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 148/167] 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 149/167] 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 150/167] 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 151/167] 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 152/167] 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 153/167] 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 154/167] 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 155/167] 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 156/167] 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 157/167] 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 158/167] 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 159/167] 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 160/167] 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 161/167] 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 162/167] 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 163/167] 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 164/167] 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 165/167] 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 166/167] 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 167/167] 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 053/167] 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 054/167] 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 055/167] 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 056/167] 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 057/167] 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 058/167] 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 059/167] 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 060/167] 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 061/167] 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 062/167] 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 063/167] 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 064/167] 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 065/167] 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 066/167] 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 067/167] 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 068/167] 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 069/167] 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 070/167] 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 zcmeFZWmr|+7B-9sQYsxvxIsVxN$CzjQV`g5NV93ATN*(^kq{&$H`3jRgdhmg-O{Df z`Oc-!qn^0Vch2|c{quTVus3_JwdR~-j5)?V?lGoMl^)!`f_)tu1qJ1bjPyNa6clU% z6ckiP%uC>tQx&Fq6qL&%2nh)#83_rP(qmgwgp~;jiuBVMbqo!a4w7W;7g15Y;&BA- zg!eMgq6oxs>Tia~N}~o47{PKzKBoIwk`foC<Q8Muabytt&eAo2QYF>@Kd#GQUK)05QbU7qe|)Cw#-mY7V7G^~ui&8YTNF01FS zBF=Tceb4Y0eru3vDknZ~>=5Ku`$~XaCny(hKa?XqoqEXcz(0z$GMFsWSWbeFcrORP z@W~a2OODjeiw+mxQ*-VfIJ~l6^7*_1)YlBLTVf@fGj4|r!kigDgyX7@ zJ!AP~djHCpbcTJ&0W)<3VTd>?(UHc(ELz?N2>k?oHeP)zm5M0B%$8%EQbKX!!*^q@ zI&LR+EF*NjAu+r;H*b>>vh$KQz}TeOUDjWTz9cIgIE1lW4pDj5$}m?)mMLVz@&V`h z*G0Tq5~5b7_BokAJ2A$cuk!W=@_sZA>Ue|6N#2BD=wG5iAxA^K6xEz?f!>b-w!Un= z9Ox37{HO_?APOC=>0!s^2@n|L#zcouedp#}8&eG4WU?KbVO)=5| zT(w1-`F5R|A*KE+xJ_QE{HkqE_7XE8wc;XoCUD)I{qJaBtGvVXET!z6+y6##P+!El zyi|v~u}{fH&&|R0!HFPM9x-d&9EzZfnN1tq)o7@>RNvY$-x0{)>~lcwSyCcT!aK~r&+GGWhZsBS!_?;| zLJtMuG+mu6#zixvVoft4t`FW{WBJ5lgYuz)HFS75QRC3%;%DpgPu<5CQBq=keSP0g zR~M0>?71~nlvj0GoqL!x+=>&_j_+bis~ts^FGNGMg9qTQQ&v`3!6>k2mUa; zOX*^$(ii3pc1G=zsF;5JA1he7^E?TuqOW6VhB4sH%WM=;j4JG#HTTaUo^PXg1O!=d7`g1 zyBA0D;%Coap1?1dQ4+*onvLFmbzoSBI_*0=Z+$>~a$zdi=VmXa6dHyfsjmc?#0MIA z`j>c}SmT(%nAtOWtMqj@37*#6BTvVe2xDlWnB$x?oO7R}nzJ{&^%cK3E6F{Y z`_YFBxeJ*K`3t7sBp>q}-j$3tl=zxPtMvG`!);3jrtixwUSk+sH3`wz z?}rz1%jGNQ>ur>b$vKtO#PG{`)wvhDS07#24O^|hZhXb^O2s90qb#XRspz&V;ob^0 zi_+cZ-Jad82?AU_2;qJjgr+4r;%oV$rS)h2+})6f&&<4A~UZtz#I;dvLk@lAleOX>yVHun(^w8apPA;aBQ%tRtE_|?6 zGUah-{r?wV#ztlChoRe*|=hKvfV@ZiKHF4L zpTTkPB)53@didP4tZUZn7Cfsw-0WA`NyrQD3L_>g(=F$0Ul^5iF2u`3GE5E(tD4tz zmbLEiNd&($TwovK7$fsCl`>5;RW9u|r8G_Hw(cVAUP{pbaqAB~c#f@(Wsk$I=J?n8_ZrkoEZH^N^~|Qv4!*bLRi@O8kc=pgzgFtqeWAnF*PfU*hug8xDyCo z6f|?A`g*ebWfQ(!KE1|jdw)x3D`~1nYWx|=vnl?g=aJR8!)B$5ajfroCS9U!<5FJT z7%m*!N^1-hnf6IMSBa%$3N8=Zsa|$}TEnkUX4{pz$-3fFH=*G0f$qs4{Ru2$)Ruma^kMn-| zbA#thH@BoTC7-5WPZ#678T%c+9ZWB3+n$l#HR=i^dQW5^YsARF#o(lEaXyk-oY(H0 zU7Z<|p?kyZu1#l^!~B)@rEn2`F)vh?W?x5f_s2({c33COZMZ5${G%Vnd~P@TKD4d3 zeR`O8AcT739rn9^`IIi=S3M=3C2FR-#>XacX8YKq&DgVfuX|sI^KCi2aj0Cnx>FU%g^4 zb&73$dstdy7Th((9?Wse<@vu?`M+7;>SIW)z#mu z^lZoOCT?Y-%73P#*%(@kbQ~NXBveaba$%mX3M!GR$+EO=8EbQjU5m{>R(`H)pU;u{ zSTW||U^;St?GRYl_Ow#vXIG=qHpRq%L>rr{qdNN5y1EU4Rhi}=qw{67drsO;@=X(G zD@v^OE5j;WHbTZftIa=DDeX63>eDJy(;6>jvL4Qy^sOadqgdnK`aG#pLAoBZ>D9pM zHNAT}d#sJihC4u6#;@TN;+<1Qm7Ah9JsdUJI~dt-A^6tw+bs-E1X_qi#Y8$Zm&d}X_#q!I(jYi z_|uwBzuV$r5Y8s)>iFas?&;>Nw@Wq=`V!psD-Z^I;-7h zwFb&RtvwyKoszEo>=fPo^32n0Pq%rp&*S=j=N9>7;o1AmNFm)#-TODt zgw1`(&bL3^oO(kVi9_YHl6)SpmvNH2(w|_)(i5?Rwy)zZyyv!$^EIrep3cYOOmnwy z@5{T!lXHtL^8Vpd*>4szyK_93Y%3a_yvE0F^!uFap4Ikj$%&A8j(mN7__Zs0K~Yge z@l5=vWG`=Wc+qxtdh_Z6?&Xkbf`Suu6o2CGF0`3lXB2Y=l-}9HXB=wwXVIkh>`h~C zZl@H@J-f}ii8Ao+e1Ammz{TO>lX~shuQ;oD*%$C6F2ddsroNH7GWf1E;Y5dl>7MJ= z5u#nLn<5ua64QAd&hYkon4@xQ{8Gk!EwZR6zlS5xmDlLA!7r z1s%M*0DgookpFrwb>TM3#UG!eqM!sLP|*Im<^gzx{zZWw=$@ai7heRSV1U0s-h$$q ziu(7}*aWE;|9+3^2);uRQ<0F70k0}Xk4;Q$9L#MUL;B&w-~%i>X-x+d6cRe<=Yotf z?KXJ+FhW(sQA0tV-^kXQ#lYCs(1gX++75aSil8e$cx!FqXaIAywz6^HcNL=faRonk z4}Hu^1N(7_qoojyhJq4I!uGKVjGKjxg^flS8wP_3J~lSxSH36p*X`ghAsTZ>M>~F2 zRu>l+7MHs$wvWwN+4=bRSlKvOIXIZX70eE9HjW0a%r*`;e?H{z=iD=KFnWxzb41wM zz@X$}|*4JO- z{`uu!Hwv;sOaDU@Kjr-6Qy^$zY(dsvN)yIDMb|zDdZa+yQ&a`7z{;S17x=-i+dp5y z`wQ%QyuFmpC@7*RGWWz(T`#OpV#E`{$M(Kr1YLWKhlk%Hs@fIbR7QWs?)_U+5?o3gar0OccNL_M#ZO?|Q+L)Q}kh6Lc}Wj%~LT7mtQy192Bcbtji3AAM2* zp-H&Y>dB|NtuNJ|bbE69ijCS8+HfwQ;fbRB_rL0ha>S#0KiX&aW5S-Gp#ImtcrW+` zoKXKyx4>qKQN+X}F;WU{{x2iJX8ZzDe9rg?{_DB@oYhPk7!4OAiRFJA4UZ5eO%^?K z>%SKTG#nK+9_@}@?+VBNb{jj8B0H8h&HpBk|F^Jzkn;b1jq%YJA^PbPdF|87ZKtPJ z2Xdp0TzWSCvsR76P{f+8u02KDoIl=btlVli{W!^wHH=G(#*ViaOo^*6;sT_&B>GYfh;{@kp1&zg>Q z*YZ_#(zVpXW=a;T>-q7;c9aP_FBeyM+z!e&B-&r=$wyDmKfLD^el8~QZ z{KIahFmFp&b#sr8_?#c>s|PBbpH8KUU9_C4tM<5^qg`p^JYo=BeO|DCDKO$c3yhXo zG>Q#T(v_6jn^JMRzvBH(sb@t@g>@xeurwsG@cGI2&FQzArEg8U6P&l-VT|jaAE{eT z);N7uGaIAP3=4Bw9ViZ_a{r)JVWqi~3>iXlE{m7tmcyR{(b^+#*tS!7yxrXxA7*A*jc*Mj z)gG^~&UaY)Wb>6B<7w5ObFCxjy(o&*D>!fP0n5!VD(Y1=5ZqJFY;FrDA0IVKv0h5? zX-LFvX_){2^08=G4%@b=>9E=PNRdrOTs`|%V(0JOJlbMnzH48=rrARE9~)bb5ZJU! z5joj@Ux1tyK3+E}HS4L!l#8pu4gmWnk;`(ZE7@yLbKGOAK36v0-{;oEZy)_61r?jv z8$7Z=eD))2qm{_{Nol@Lb)Rav&R`on96S@Z^7|)&98=H6IJDE7-4E=Vzyrdu@?|-$J--rzVo#^cw7MPHqH}>J{Bec5VK| zzxKAfpcUg!(L|A>S`NJ}RunlMXp5k8_;{_E_NNq{gpbQd4oVU@1(K6O_YGu~Yt z-0S9<*62>)`dIgnnatwY2u`^&R-T7L(56mV4dku+WE&W75mP}`de_=E`X9~-)3~y! z4AE~jQDwh(xRf%UAro!2w?3N17??nqlU|MLf8_b4Glqp1+NSDhO5dM2Z;av7cWuso z+5AsZ^p(ZIqkYS1a6o#SRXV4(8ra4*;p^_seoMm^!b^Ky4Sry=^eK?7G0~(mkeqe8s^8FwYKX7C-?r4i7y471_)VR z-#TvXEhgHIiJTuQ9z*^M?0oL-TiDpx6Bni1Z}`-0G@hURFnsJlqqb+omDAqil;R+&8ZCta+8d*ycfX1ssa zD}h^Hzdtn&r&5K#qjVm57kfw35V*|8gHrs?>*mMnrI9#IG+TsP5DYtB#8x;UE z=y`sX9Jg<$B(zF;|F0|j({vv{gd9xxm{FvV>9C$hzBbwbO^W!F!^6X@PCq2xH=wOC z>R^gF_cF&7WpZ}IfN%=FKGRRHSA_fbj6F81v-3oA=8^EcMPc=r=17sV5i0j(Y6!aI z>(&l|7=)(Trh)Pc#DeHv)xI0T5)P$C3{+GSc__ z8kqNZGlfM+>JP*GQ;t8zIKhWWFZXEltNf|VkodphO5mlzau8!{SN)N&oK=6_8b|vS zy73KmqqdRhvpt^UukANB-s6g7&={0Fq4ucyRYR(*Q2%n=bUCC>IYW3 zV?+Fpqmhmj8TdPGj16ApUxo=BEx)mDr*tU;#?W@7+2cwgSuv=low=W` zV|~{*hDm*Fd-nb0Qn|(8*t&=CpTqf{6KWJ22Ey~yy3dY=8^!%Z$Iz!-=dUCRc~lJM zYY!t;b6@POIf(r+-E^8F_gxDWu~5PXyzjKi%%3Y&ViM4|ysY-8a-F$A&TE&-h3IRs z7%oVvCQthls+8!6Qa7h~A9WN|&0*J`9j%p~*i_f+5NcG}rEN4EZ`z2x%ujZ(-l@|6 zHJUFQI?*?zUVY6=WX)*pR)Zy2GmUh-jZQ)EFXV;C;7cbL8y`wrcQ6;` z{DC@{c7Vq(0E^vZGu7aI(5Tn`q*=yW$P@CmbSoHz&sd zdXD?zlGre=UF)0=c0>2jTg`bY(J=s~nORv2n=h$$*a81jVKd2Z30BEv-kaL&;3+Ih zJqV%Uy$J`!+U;f>udLLqKQ#4{$!?>3c&sl|&Kd;DFI4Sum>D#^rDlr9JN=5*%e`r8 zN=i|kbi5dU*bpn4p3kY12S7|4!1gNvh-XU^CZv@QVQ|1WoxgEj1OeI2#&d6hW1@gdfx!JRuewf>gDszP%XSKfXO#9W_|5>8#CpM||L{;V)lFblwonQM z5+pYkv3-BM-q=wHc^EWb=dwWij5hp7oPrUcGV1Lwzlj`+qLz58lKlW{_bJRkT>h0n z2+Uw{9dYf~!U5{?yi8y-QT5rT@zi;3y0H;LTkyMzI~Qo-Gy|y90F2rq4S50HnYg|k zwG|69_%Ory+Yq8kQ2~~9UVc2+hDEU0!e$@+TMwEr;Ph}VQBPKa*U5Ap+~1=jQH3vI z@mP&K@;UpSqD!`&5&fVfJK4PxN-($arh)xHaCjVeC~NSMP1t;Z;@-NbUqJE|-pSv| zl(Z%Y7@dx<b+vV;N-!r4_({HMXYe zbtRjP?b-p11L;Q=vwp*bR3w!(Kz@cC*&a@J#Ka4O5jt9oIQ(7VTg?U6_&|-%R^=$!eDuoi8=>{(-e#mz^_hra9?|i;LUSjfb zJuR531XvKAQ-dM~A?*&LY=!CfGfcp8)TAOEm;kiZc#b#yhnL*;0V=q85C??p=Pw)g zJHnviZ*xhHfYdKfEk9v*rN8~sRf_nR8xUp(&ha%4!Bv1H;au{os8p2lQN3^8W##== zUJNu4gYbg_QLrj>QP1$-D{VUeAzMY?eQSj?MkG1^D^%;NM3Tla<5tiEZ7g1XvX;}BeVJX7DDHV_URNv>c9FQ2o zX%1e3A+WT_HxE+&7#W1i_|WT1w2WZnhYS%Czee8H{h?OWe={; ztPBP9DHa&I>**V1@J)OIx!Lc1b8ri?FQW(l*2th1w|4^wf1M9_ zXVH%PQ_hS*o=|Erh#c(^Ik!3ok#9YmXR7Ea;jOW)#&a8_xhr4*YV)5vT`nvE5{R{a z&+~hQm@NR;KO3I$s)3NO_RF>3Gr9+d6muVVq<^!+mEz@M?-YaRH6C=8auwC@DGC$s zNSXwO$4X|L&IsFc5JQ4sX+3Wgr+-WN1!M!yNZRR{z*05YBkupUfuBPq|C^y4qLflU z#xj>eu^0n2wK;fbXEZYPm*Cj>~d~F4_oBZRZiPoW%?^!d!vhGX< zD*hfF?>2*IR8abcJb*t`sghD*q zJMW{FTiT_j;m37=mb%Vq{-}1dCXdo<=X1qUvWFI!4C=M8+j=iy21NH`gnx?@jT)>P z6JBr)sQ#Z=;xEu-r*GYAq?3-1e&43>+xZwn-of565F4Jk4RV}PNc$i|$p?NcOO(2B z0kUY@wrjuj=?$^T4ioQJxx|`_G7!HbFl-oHyLf~d`}iCUOHOR|=C6Hm0ZkStMgtrB zMy3K7ZR~b6-fzjYK$7u%a(6<=FW?##O2TiQeYgfZG~Zp-r_;B^np^UD34R5KCR^~0 zccf2mJ=joXY{S3r2?+&(B?$FvaN4 zb0dE{lHU_)0AhbO^MMfjO6{ZjYpiXlN8L47oOcwA#J9GddsGyy)ofgIuYG0Hy9Aqs zA`+kvN-=JD_7b_OxUxM*No$jDhrp{^pF?o?cHK- z+(>!r9H%W|%QFNMYmP?~@y7#36~Oo#qogx$A8S{Rj^)}0Pjwu{HCn>4ErO$ z!BfmrT4}PhcNt8WFuahaHj!rB#sVGk59J?g&38`(^WgPbZo%7>VBL@M&M^t7>`%$l z{I}ps&|@bX^hfnC?I?KHO62c#xS%=ihO){<6&c=)kcPs*s`Kloe{1l7ar-!t zXMBudmh>#gN0M4hT>pI8Z3Tkb&e zq~_&=9d_v|gZGyN4i};~AomR-&4}Alk{J|h0B+tdGV5y6xyz^qEF z4=(wt0?3e+-p2RSRu*ELvUV-4Pv7>4H?APBBxvhJbEj3x`M(2>2B`i-UsIYHkM=TF zf+?OT%nWi2>%Ar$;nUo!DSJce4&sfCF1CCT8zmCzxDCK!-jTOg&H&_bdbA6XedFnp zVe3`%5v6Go!CIxJ-E*3*DfD2sA^a9N{-D1>-Un$(K4*vAuG=$Y7$W_U1$C$DT>absZr^(XOS^SZfky(0 zG}pPV{ER_SP{r`tn@FqoXjmZ7Ek-8742*+bL>T)veM`F)aKE}=z%1`^pKO+2KrpdU5Du*aRoZn3mVa?9R!*ldmH-JJ1UP(7XKkQU9P4FxQ}F`I|q@#aI;>w$$Ox zQC=}|zkuh5@9Cw}j5CDv`vt~rCSqg|+1 z4=OvRYWZ4RdCHlx;ujx+PXZ#o@51~78X^Qwe`WNHAjB0t_I@2MDg$OsCwV~mL(jg; zz_mP^iZ`tR;S$7&BU?7>jbjShiK@BA3j0GvM5+9axSPV5MvNz8742$Fr#%{$%ES5B zB|z}8bnlmPv5gs}5BBt3iW1PD26`!PXm@-^s^%HEX_qq}vNvW!RN-}Y!m5|R@Iaw% z44qVG#&HqQWAp8im4kWe+Ha)7ttuy-9SoX5#-*O44k~4!c&HKBe&`A8KjnT(!!Hz* z3%ri}WW-(k+Yr7$FnR|4aA2ZkP?}>YSenN3V+rr44YW4jfzY_x6Fk1!9Nae--zB-4 zEHLpcVKcsqfg&*jh?MpnZ=DVh#FuZ|CfKO2un8JQX7utjO{}q9ULP^Ig==d~>fKan zL@`sl4df=CUPkqo;{ud~(%aP;V6Cq$(_=85>_FioUzdFI99MR2CfJVMFMt}>kM_4k z;6dmCpF*|f7WZNQkxL`?4M)owmCtomyf{GosQ z^b80JYw=Z+p-M$XiJ<-*HXV3Jwj(17XIv~`hnJ6SS&H@`i@dxZ$>dK;FC8y-g2|nf z3!8n)96L@Yz;ac$>}V=mYmm7D>(_3^m{6X@k+x}nyKGzfyVT0gNfC3c4hqU_anpm^Wjd$xc zK7IkCTvQLM$o&EylC`d{Tm)Ob-4BGQsRk%MRN`kh{uU;fdsaI;X+eo;_jPg+?>aBQ zSaBozXcKvClTW{WWQFhvR6_LJ4IwV+4~3qk!RMk&4B