mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
Signed-off-by: Vivek Karunai Kiri Ragavan <[email protected]>
625 lines
25 KiB
Go
625 lines
25 KiB
Go
/*
|
|
Copyright 2025 The KServe Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package llmisvc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
kedav1alpha1 "github.com/kedacore/keda/v2/apis/keda/v1alpha1"
|
|
wvav1alpha1 "github.com/llm-d/llm-d-workload-variant-autoscaler/api/v1alpha1"
|
|
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/equality"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/utils/ptr"
|
|
"knative.dev/pkg/kmeta"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
lwsapi "sigs.k8s.io/lws/api/leaderworkerset/v1"
|
|
|
|
"github.com/kserve/kserve/pkg/apis/serving/v1alpha2"
|
|
"github.com/kserve/kserve/pkg/constants"
|
|
"github.com/kserve/kserve/pkg/utils"
|
|
)
|
|
|
|
const (
|
|
wvaDesiredReplicasMetricName = "wva_desired_replicas"
|
|
variantNameLabelKey = "variant_name"
|
|
acceleratorNameLabelKey = "inference.optimization/acceleratorName"
|
|
)
|
|
|
|
// reconcileScaling manages the autoscaling resources (VariantAutoscaling + HPA or KEDA ScaledObject)
|
|
// for the LLM workload. When scaling is configured, it creates a VariantAutoscaling CR for WVA to
|
|
// compute desired replicas and an actuator (HPA or KEDA ScaledObject) to enforce them.
|
|
// When scaling is removed (or the workload is stopped), it cleans up any existing autoscaling resources.
|
|
//
|
|
// A missing scaling CRD (NoMatchError) is treated as a hard failure: the LLMInferenceService is
|
|
// misconfigured and reconciliation of remaining resources is blocked until the CRD is installed.
|
|
func (r *LLMISVCReconciler) reconcileScaling(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, config *Config) error {
|
|
logger := log.FromContext(ctx).WithName("reconcileScaling")
|
|
ctx = log.IntoContext(ctx, logger)
|
|
|
|
if err := r.reconcileWorkloadScaling(ctx, llmSvc, config, mainWorkloadScalingParams(llmSvc)); err != nil {
|
|
return fmt.Errorf("failed to reconcile main workload scaling: %w", err)
|
|
}
|
|
|
|
if err := r.reconcileWorkloadScaling(ctx, llmSvc, config, prefillWorkloadScalingParams(llmSvc)); err != nil {
|
|
return fmt.Errorf("failed to reconcile prefill workload scaling: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// workloadScalingParams captures the per-workload differences between main and prefill
|
|
// scaling so that reconcileWorkloadScaling can handle both without duplication.
|
|
type workloadScalingParams struct {
|
|
name string
|
|
scaling *v1alpha2.ScalingSpec
|
|
scaleTargetRef autoscalingv2.CrossVersionObjectReference
|
|
vaName string
|
|
hpaName string
|
|
scaledObjectName string
|
|
workloadLabels map[string]string
|
|
markReady func()
|
|
markNotReady func(reason, messageFormat string, messageA ...interface{})
|
|
markUnset func()
|
|
}
|
|
|
|
func mainWorkloadScalingParams(llmSvc *v1alpha2.LLMInferenceService) workloadScalingParams {
|
|
return workloadScalingParams{
|
|
name: "main",
|
|
scaling: llmSvc.Spec.Scaling,
|
|
scaleTargetRef: mainScaleTargetRef(llmSvc),
|
|
vaName: mainVAName(llmSvc),
|
|
hpaName: mainHPAName(llmSvc),
|
|
scaledObjectName: mainScaledObjectName(llmSvc),
|
|
workloadLabels: llmSvc.Spec.Labels,
|
|
markReady: llmSvc.MarkScalingReady,
|
|
markNotReady: llmSvc.MarkScalingNotReady,
|
|
markUnset: llmSvc.MarkScalingUnset,
|
|
}
|
|
}
|
|
|
|
func prefillWorkloadScalingParams(llmSvc *v1alpha2.LLMInferenceService) workloadScalingParams {
|
|
var scaling *v1alpha2.ScalingSpec
|
|
var labels map[string]string
|
|
if llmSvc.Spec.Prefill != nil {
|
|
scaling = llmSvc.Spec.Prefill.Scaling
|
|
labels = llmSvc.Spec.Prefill.Labels
|
|
}
|
|
return workloadScalingParams{
|
|
name: "prefill",
|
|
scaling: scaling,
|
|
scaleTargetRef: prefillScaleTargetRef(llmSvc),
|
|
vaName: prefillVAName(llmSvc),
|
|
hpaName: prefillHPAName(llmSvc),
|
|
scaledObjectName: prefillScaledObjectName(llmSvc),
|
|
workloadLabels: labels,
|
|
markReady: llmSvc.MarkPrefillScalingReady,
|
|
markNotReady: llmSvc.MarkPrefillScalingNotReady,
|
|
markUnset: llmSvc.MarkPrefillScalingUnset,
|
|
}
|
|
}
|
|
|
|
// reconcileWorkloadScaling reconciles all scaling resources (VA, actuator) and propagates
|
|
// status for a single workload (main or prefill). The workloadScalingParams struct captures
|
|
// all per-workload differences so this method does not need to know which workload it is handling.
|
|
func (r *LLMISVCReconciler) reconcileWorkloadScaling(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, config *Config, p workloadScalingParams) error {
|
|
if err := r.reconcileVA(ctx, llmSvc, p.scaling, p.scaleTargetRef, p.vaName, p.workloadLabels); err != nil {
|
|
return fmt.Errorf("failed to reconcile %s VA: %w", p.name, err)
|
|
}
|
|
|
|
if err := r.reconcileActuator(ctx, llmSvc, p.scaling, config, p.scaleTargetRef, p.vaName, p.hpaName, p.scaledObjectName); err != nil {
|
|
return fmt.Errorf("failed to reconcile %s actuator: %w", p.name, err)
|
|
}
|
|
|
|
return r.propagateScalingStatus(ctx, llmSvc, p.scaling, p.hpaName, p.scaledObjectName, p.markReady, p.markNotReady, p.markUnset)
|
|
}
|
|
|
|
// reconcileHPA creates or updates an HPA for the workload, or deletes it when not needed.
|
|
// The HPA reads wva_desired_replicas via the Kubernetes external metrics API, which requires
|
|
// a Prometheus Adapter to be pre-installed in the cluster. The controller cannot validate
|
|
// whether the Prometheus Adapter is present or correctly configured — if it is missing,
|
|
// the HPA will silently enter an Unknown state and stop scaling.
|
|
func (r *LLMISVCReconciler) reconcileHPA(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, isStopped bool, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, hpaName string) error {
|
|
if scaling == nil || scaling.WVA == nil || isStopped || scaling.WVA.HPA == nil {
|
|
return r.deleteHPAIfExists(ctx, llmSvc, hpaName)
|
|
}
|
|
|
|
expected := expectedHPA(llmSvc, scaling, scaleTargetRef, vaName, hpaName)
|
|
return Reconcile(ctx, r, llmSvc, &autoscalingv2.HorizontalPodAutoscaler{}, expected, semanticHPAIsEqual)
|
|
}
|
|
|
|
// deleteHPAIfExists deletes the HPA if it exists.
|
|
func (r *LLMISVCReconciler) deleteHPAIfExists(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, hpaName string) error {
|
|
hpa := &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: hpaName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
},
|
|
}
|
|
return Delete(ctx, r, llmSvc, hpa)
|
|
}
|
|
|
|
// expectedHPA constructs the desired HPA resource from the LLMISVC scaling spec.
|
|
// vaName is used as the metric selector label because WVA emits wva_desired_replicas keyed by VA name.
|
|
//
|
|
// The HPA uses an external metric (wva_desired_replicas) with target=1 so that it acts as a
|
|
// direct actuator for WVA's decisions rather than an independent scaling algorithm.
|
|
// WVA computes and publishes the desired replica count; HPA reads it and enforces it on the workload.
|
|
func expectedHPA(llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, hpaName string) *autoscalingv2.HorizontalPodAutoscaler {
|
|
labels := scalingLabels(llmSvc)
|
|
|
|
minReplicas := ptr.To(ptr.Deref(scaling.MinReplicas, 1))
|
|
|
|
hpa := &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: hpaName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
Labels: labels,
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
|
|
},
|
|
},
|
|
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
|
|
ScaleTargetRef: scaleTargetRef,
|
|
MinReplicas: minReplicas,
|
|
MaxReplicas: scaling.MaxReplicas,
|
|
Metrics: []autoscalingv2.MetricSpec{
|
|
{
|
|
Type: autoscalingv2.ExternalMetricSourceType,
|
|
External: &autoscalingv2.ExternalMetricSource{
|
|
Metric: autoscalingv2.MetricIdentifier{
|
|
Name: wvaDesiredReplicasMetricName,
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{
|
|
variantNameLabelKey: vaName,
|
|
},
|
|
},
|
|
},
|
|
// Target is set to 1 with ValueMetricType so HPA computes:
|
|
// desired_replicas = ceil(wva_desired_replicas / 1) = wva_desired_replicas
|
|
// This makes HPA a pass-through: it blindly follows the absolute replica count
|
|
// emitted by WVA, rather than doing its own scaling arithmetic.
|
|
// WVA is the sole source of scaling decisions; HPA is purely the enforcement layer.
|
|
Target: autoscalingv2.MetricTarget{
|
|
Type: autoscalingv2.ValueMetricType,
|
|
Value: resource.NewQuantity(1, resource.DecimalSI),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if scaling.WVA != nil && scaling.WVA.HPA != nil && scaling.WVA.HPA.Behavior != nil {
|
|
hpa.Spec.Behavior = scaling.WVA.HPA.Behavior
|
|
}
|
|
|
|
return hpa
|
|
}
|
|
|
|
// reconcileActuator reconciles the scaling actuators (HPA, KEDA ScaledObject) for the workload.
|
|
func (r *LLMISVCReconciler) reconcileActuator(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, config *Config, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, hpaName, scaledObjectName string) error {
|
|
isStopped := utils.GetForceStopRuntime(llmSvc)
|
|
|
|
if err := r.reconcileKEDAScaledObject(ctx, llmSvc, scaling, isStopped, config, scaleTargetRef, vaName, scaledObjectName); err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.reconcileHPA(ctx, llmSvc, scaling, isStopped, scaleTargetRef, vaName, hpaName)
|
|
}
|
|
|
|
// propagateScalingStatus determines which actuator is active and propagates its status.
|
|
// When no scaling is configured (or the service is stopped), the condition is cleared.
|
|
func (r *LLMISVCReconciler) propagateScalingStatus(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, hpaName, scaledObjectName string, ready func(), notReady func(reason, messageFormat string, messageA ...interface{}), unset func()) error {
|
|
isStopped := utils.GetForceStopRuntime(llmSvc)
|
|
|
|
if scaling == nil || scaling.WVA == nil || isStopped {
|
|
unset()
|
|
return nil
|
|
}
|
|
|
|
if scaling.WVA.HPA != nil {
|
|
expected := &autoscalingv2.HorizontalPodAutoscaler{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: hpaName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
},
|
|
}
|
|
return r.propagateHPAStatus(ctx, expected, ready, notReady)
|
|
}
|
|
|
|
if scaling.WVA.KEDA != nil {
|
|
expected := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: scaledObjectName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
},
|
|
}
|
|
return r.propagateScaledObjectStatus(ctx, expected, ready, notReady)
|
|
}
|
|
|
|
unset()
|
|
return nil
|
|
}
|
|
|
|
// propagateHPAStatus reads the live HPA status and maps its conditions to a ScalingReady
|
|
// condition on the LLMInferenceService. AbleToScale=False or ScalingActive=False means the
|
|
// metrics pipeline is broken and sets ScalingReady=False.
|
|
func (r *LLMISVCReconciler) propagateHPAStatus(ctx context.Context, expected *autoscalingv2.HorizontalPodAutoscaler, ready func(), notReady func(reason, messageFormat string, messageA ...interface{})) error {
|
|
curr := &autoscalingv2.HorizontalPodAutoscaler{}
|
|
if err := r.Get(ctx, client.ObjectKeyFromObject(expected), curr); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
notReady("HPAProgressing", "HPA not yet visible in cache")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to get current HPA %s/%s: %w", expected.GetNamespace(), expected.GetName(), err)
|
|
}
|
|
|
|
foundAny := false
|
|
|
|
for _, cond := range curr.Status.Conditions {
|
|
switch cond.Type {
|
|
case autoscalingv2.AbleToScale, autoscalingv2.ScalingActive:
|
|
foundAny = true
|
|
if cond.Status == corev1.ConditionFalse {
|
|
notReady(cond.Reason, cond.Message)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundAny {
|
|
notReady("HPAProgressing", "HPA conditions not yet available")
|
|
return nil
|
|
}
|
|
|
|
ready()
|
|
return nil
|
|
}
|
|
|
|
// propagateScaledObjectStatus reads the live KEDA ScaledObject status and maps its conditions
|
|
// to a ScalingReady condition on the LLMInferenceService. Ready=False means a trigger/config
|
|
// issue and sets ScalingReady=False.
|
|
func (r *LLMISVCReconciler) propagateScaledObjectStatus(ctx context.Context, expected *kedav1alpha1.ScaledObject, ready func(), notReady func(reason, messageFormat string, messageA ...interface{})) error {
|
|
curr := &kedav1alpha1.ScaledObject{}
|
|
if err := r.Get(ctx, client.ObjectKeyFromObject(expected), curr); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
notReady("ScaledObjectProgressing", "ScaledObject not yet visible in cache")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to get current ScaledObject %s/%s: %w", expected.GetNamespace(), expected.GetName(), err)
|
|
}
|
|
|
|
conditions := curr.Status.Conditions
|
|
if conditions == nil || !conditions.AreInitialized() {
|
|
notReady("ScaledObjectProgressing", "ScaledObject conditions not yet available")
|
|
return nil
|
|
}
|
|
|
|
readyCond := conditions.GetReadyCondition()
|
|
if readyCond.Status == metav1.ConditionFalse {
|
|
notReady(readyCond.Reason, readyCond.Message)
|
|
return nil
|
|
}
|
|
|
|
if readyCond.Status != metav1.ConditionTrue {
|
|
notReady("ScaledObjectProgressing", "ScaledObject is not yet ready")
|
|
return nil
|
|
}
|
|
|
|
ready()
|
|
return nil
|
|
}
|
|
|
|
// validateAutoscalingConfig checks that the WVAAutoscalingConfig is valid for use with KEDA.
|
|
// It returns an error if the config is nil, if prometheus.url is missing, or if the auth
|
|
// fields are only partially configured (both prometheus.authModes and prometheus.triggerAuthName
|
|
// must be set together or both left empty).
|
|
func validateAutoscalingConfig(cfg *WVAAutoscalingConfig) error {
|
|
if cfg == nil || cfg.Prometheus.URL == "" {
|
|
return fmt.Errorf("%s.prometheus.url is required in inferenceservice-config when using KEDA", autoscalingConfigName)
|
|
}
|
|
if (cfg.Prometheus.TriggerAuthName == "") != (cfg.Prometheus.AuthModes == "") {
|
|
return fmt.Errorf("%s.prometheus.authModes and %s.prometheus.triggerAuthName must both be set or both be empty", autoscalingConfigName, autoscalingConfigName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileKEDAScaledObject creates or updates a KEDA ScaledObject, or deletes it when not needed.
|
|
func (r *LLMISVCReconciler) reconcileKEDAScaledObject(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, isStopped bool, config *Config, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, scaledObjectName string) error {
|
|
if scaling == nil || scaling.WVA == nil || isStopped || scaling.WVA.KEDA == nil {
|
|
return r.deleteScaledObjectIfExists(ctx, llmSvc, scaledObjectName)
|
|
}
|
|
|
|
if err := validateAutoscalingConfig(config.WVAAutoscalingConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
expected := expectedScaledObject(llmSvc, scaling, config, scaleTargetRef, vaName, scaledObjectName)
|
|
return Reconcile(ctx, r, llmSvc, &kedav1alpha1.ScaledObject{}, expected, semanticScaledObjectIsEqual,
|
|
PreserveKEDAManagedMetadata(),
|
|
)
|
|
}
|
|
|
|
// deleteScaledObjectIfExists deletes the ScaledObject if it exists.
|
|
func (r *LLMISVCReconciler) deleteScaledObjectIfExists(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaledObjectName string) error {
|
|
so := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: scaledObjectName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
},
|
|
}
|
|
return Delete(ctx, r, llmSvc, so)
|
|
}
|
|
|
|
// expectedScaledObject constructs the desired KEDA ScaledObject from the LLMISVC scaling spec.
|
|
// The Prometheus server address and TLS settings come from the controller config (inferenceservice-config ConfigMap).
|
|
func expectedScaledObject(llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, config *Config, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName, scaledObjectName string) *kedav1alpha1.ScaledObject {
|
|
labels := scalingLabels(llmSvc)
|
|
keda := scaling.WVA.KEDA
|
|
|
|
minReplicas := ptr.To(ptr.Deref(scaling.MinReplicas, 1))
|
|
|
|
// variant_name matches the VariantAutoscaling CR name, which WVA uses as a label when emitting wva_desired_replicas.
|
|
// exported_namespace is used instead of namespace because Prometheus renames the namespace label emitted by WVA
|
|
// to exported_namespace during scraping — this happens because namespace is a Prometheus-reserved label that
|
|
// Prometheus itself sets to the scrape target's namespace (the WVA controller namespace). WVA's original
|
|
// namespace label (the workload namespace) is therefore preserved under the exported_namespace name.
|
|
// The VariantAutoscaling CR always lives in the same namespace as the LLMInferenceService, so llmSvc.GetNamespace()
|
|
// is the correct value to filter on.
|
|
query := fmt.Sprintf(`wva_desired_replicas{variant_name="%s",exported_namespace="%s"}`, vaName, llmSvc.GetNamespace())
|
|
|
|
so := &kedav1alpha1.ScaledObject{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: scaledObjectName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
Labels: labels,
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
|
|
},
|
|
},
|
|
Spec: kedav1alpha1.ScaledObjectSpec{
|
|
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
|
|
APIVersion: scaleTargetRef.APIVersion,
|
|
Kind: scaleTargetRef.Kind,
|
|
Name: scaleTargetRef.Name,
|
|
},
|
|
MinReplicaCount: minReplicas,
|
|
MaxReplicaCount: &scaling.MaxReplicas,
|
|
PollingInterval: keda.PollingInterval,
|
|
CooldownPeriod: keda.CooldownPeriod,
|
|
IdleReplicaCount: keda.IdleReplicaCount,
|
|
Fallback: keda.Fallback,
|
|
Advanced: keda.Advanced,
|
|
InitialCooldownPeriod: keda.InitialCooldownPeriod,
|
|
Triggers: []kedav1alpha1.ScaleTriggers{
|
|
prometheusTrigger(config.WVAAutoscalingConfig, query),
|
|
},
|
|
},
|
|
}
|
|
|
|
return so
|
|
}
|
|
|
|
// prometheusTrigger builds the KEDA ScaleTriggers entry for the Prometheus scaler.
|
|
// Authentication is optional: when Prometheus.AuthModes and Prometheus.TriggerAuthName are
|
|
// set in the config, the trigger will carry authModes metadata and an AuthenticationRef
|
|
// pointing to the pre-existing TriggerAuthentication or ClusterTriggerAuthentication CR.
|
|
func prometheusTrigger(cfg *WVAAutoscalingConfig, query string) kedav1alpha1.ScaleTriggers {
|
|
prom := &cfg.Prometheus
|
|
trigger := kedav1alpha1.ScaleTriggers{
|
|
Type: "prometheus",
|
|
Name: "wva-desired-replicas",
|
|
Metadata: map[string]string{
|
|
"serverAddress": prom.URL,
|
|
"query": query,
|
|
"threshold": "1",
|
|
"unsafeSsl": strconv.FormatBool(prom.TLSInsecureSkipVerify),
|
|
},
|
|
}
|
|
|
|
if prom.AuthModes != "" {
|
|
trigger.Metadata["authModes"] = prom.AuthModes
|
|
}
|
|
|
|
if prom.TriggerAuthName != "" {
|
|
trigger.AuthenticationRef = &kedav1alpha1.AuthenticationRef{
|
|
Name: prom.TriggerAuthName,
|
|
Kind: prom.TriggerAuthKind,
|
|
}
|
|
}
|
|
|
|
return trigger
|
|
}
|
|
|
|
// reconcileVA creates, updates, or deletes a VariantAutoscaling CR based on the scaling configuration.
|
|
// The VA tells the WVA controller to compute wva_desired_replicas for this workload.
|
|
// workloadLabels are the labels from the WorkloadSpec for the specific workload (decode or prefill).
|
|
func (r *LLMISVCReconciler) reconcileVA(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName string, workloadLabels map[string]string) error {
|
|
isStopped := utils.GetForceStopRuntime(llmSvc)
|
|
|
|
if scaling == nil || scaling.WVA == nil || isStopped {
|
|
return r.deleteVAIfExists(ctx, llmSvc, vaName)
|
|
}
|
|
|
|
expected := expectedVA(llmSvc, scaling, scaleTargetRef, vaName, workloadLabels)
|
|
return Reconcile(ctx, r, llmSvc, &wvav1alpha1.VariantAutoscaling{}, expected, semanticVAIsEqual)
|
|
}
|
|
|
|
// deleteVAIfExists deletes the VariantAutoscaling if it exists.
|
|
func (r *LLMISVCReconciler) deleteVAIfExists(ctx context.Context, llmSvc *v1alpha2.LLMInferenceService, vaName string) error {
|
|
va := &wvav1alpha1.VariantAutoscaling{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: vaName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
},
|
|
}
|
|
return Delete(ctx, r, llmSvc, va)
|
|
}
|
|
|
|
// expectedVA constructs the desired VariantAutoscaling resource from the LLMISVC spec.
|
|
// workloadLabels are the labels from the WorkloadSpec for the specific workload (decode or prefill).
|
|
// If the workload labels contain inference.optimization/acceleratorName, that value is propagated
|
|
// to the VA label. Otherwise the label is set to "unknown".
|
|
func expectedVA(llmSvc *v1alpha2.LLMInferenceService, scaling *v1alpha2.ScalingSpec, scaleTargetRef autoscalingv2.CrossVersionObjectReference, vaName string, workloadLabels map[string]string) *wvav1alpha1.VariantAutoscaling {
|
|
labels := scalingLabels(llmSvc)
|
|
accelerator := "unknown"
|
|
if val, ok := workloadLabels[acceleratorNameLabelKey]; ok && val != "" {
|
|
accelerator = val
|
|
}
|
|
labels[acceleratorNameLabelKey] = accelerator
|
|
|
|
modelID := llmSvc.Spec.Model.URI.String()
|
|
if llmSvc.Spec.Model.Name != nil {
|
|
modelID = *llmSvc.Spec.Model.Name
|
|
}
|
|
|
|
minReplicas := ptr.To(ptr.Deref(scaling.MinReplicas, 1))
|
|
|
|
va := &wvav1alpha1.VariantAutoscaling{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: vaName,
|
|
Namespace: llmSvc.GetNamespace(),
|
|
Labels: labels,
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
*metav1.NewControllerRef(llmSvc, v1alpha2.LLMInferenceServiceGVK),
|
|
},
|
|
},
|
|
Spec: wvav1alpha1.VariantAutoscalingSpec{
|
|
ScaleTargetRef: scaleTargetRef,
|
|
ModelID: modelID,
|
|
MinReplicas: minReplicas,
|
|
MaxReplicas: scaling.MaxReplicas,
|
|
VariantAutoscalingConfigSpec: wvav1alpha1.VariantAutoscalingConfigSpec{
|
|
VariantCost: scaling.WVA.VariantCost,
|
|
},
|
|
},
|
|
}
|
|
|
|
return va
|
|
}
|
|
|
|
func semanticVAIsEqual(expected, curr *wvav1alpha1.VariantAutoscaling) bool {
|
|
return equality.Semantic.DeepEqual(expected.Spec, curr.Spec) &&
|
|
equality.Semantic.DeepEqual(expected.Labels, curr.Labels) &&
|
|
equality.Semantic.DeepEqual(expected.Annotations, curr.Annotations)
|
|
}
|
|
|
|
func semanticHPAIsEqual(expected, curr *autoscalingv2.HorizontalPodAutoscaler) bool {
|
|
return equality.Semantic.DeepEqual(expected.Spec, curr.Spec) &&
|
|
equality.Semantic.DeepEqual(expected.Labels, curr.Labels) &&
|
|
equality.Semantic.DeepEqual(expected.Annotations, curr.Annotations)
|
|
}
|
|
|
|
func semanticScaledObjectIsEqual(expected, curr *kedav1alpha1.ScaledObject) bool {
|
|
return equality.Semantic.DeepEqual(expected.Spec, curr.Spec) &&
|
|
equality.Semantic.DeepEqual(expected.Labels, curr.Labels) &&
|
|
equality.Semantic.DeepEqual(expected.Annotations, curr.Annotations)
|
|
}
|
|
|
|
// PreserveKEDAManagedMetadata returns an AfterDryRun hook that copies KEDA-managed
|
|
// metadata from the live object into expected before the real update is issued.
|
|
//
|
|
// It preserves two things:
|
|
// 1. The KEDA-injected label (scaledobject.keda.sh/name) — so that DeepEqual does
|
|
// not trigger spurious updates when KEDA adds this label after creation.
|
|
// 2. Finalizers — because the Update call is a full PUT (not a patch), which would
|
|
// otherwise wipe any finalizers that KEDA added (e.g. finalizer.keda.sh) whenever
|
|
// a spec/label change triggers an update. Copying finalizers from curr ensures they
|
|
// are preserved in the PUT body.
|
|
func PreserveKEDAManagedMetadata() UpdateOption[*kedav1alpha1.ScaledObject] {
|
|
return AfterDryRun(func(expected, _ /* expectedGiven */, curr *kedav1alpha1.ScaledObject) {
|
|
if v, ok := curr.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation]; ok {
|
|
if expected.Labels == nil {
|
|
expected.Labels = make(map[string]string)
|
|
}
|
|
expected.Labels[kedav1alpha1.ScaledObjectOwnerAnnotation] = v
|
|
}
|
|
if len(curr.Finalizers) > 0 {
|
|
expected.Finalizers = curr.Finalizers
|
|
}
|
|
})
|
|
}
|
|
|
|
func scalingLabels(llmSvc *v1alpha2.LLMInferenceService) map[string]string {
|
|
return map[string]string{
|
|
constants.KubernetesComponentLabelKey: constants.LLMComponentWorkload,
|
|
constants.KubernetesAppNameLabelKey: llmSvc.GetName(),
|
|
constants.KubernetesPartOfLabelKey: constants.LLMInferenceServicePartOfValue,
|
|
}
|
|
}
|
|
|
|
func mainScaleTargetRef(llmSvc *v1alpha2.LLMInferenceService) autoscalingv2.CrossVersionObjectReference {
|
|
if llmSvc.Spec.Worker != nil {
|
|
return autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: lwsapi.GroupVersion.String(),
|
|
Kind: "LeaderWorkerSet",
|
|
Name: mainLWSName(llmSvc),
|
|
}
|
|
}
|
|
return autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: "apps/v1",
|
|
Kind: "Deployment",
|
|
Name: mainDeploymentName(llmSvc),
|
|
}
|
|
}
|
|
|
|
func prefillScaleTargetRef(llmSvc *v1alpha2.LLMInferenceService) autoscalingv2.CrossVersionObjectReference {
|
|
if llmSvc.Spec.Prefill != nil && llmSvc.Spec.Prefill.Worker != nil {
|
|
return autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: lwsapi.GroupVersion.String(),
|
|
Kind: "LeaderWorkerSet",
|
|
Name: prefillLWSName(llmSvc),
|
|
}
|
|
}
|
|
return autoscalingv2.CrossVersionObjectReference{
|
|
APIVersion: "apps/v1",
|
|
Kind: "Deployment",
|
|
Name: prefillDeploymentName(llmSvc),
|
|
}
|
|
}
|
|
|
|
func mainHPAName(llmSvc *v1alpha2.LLMInferenceService) string {
|
|
return kmeta.ChildName(llmSvc.GetName(), "-kserve-hpa")
|
|
}
|
|
|
|
func prefillHPAName(llmSvc *v1alpha2.LLMInferenceService) string {
|
|
return kmeta.ChildName(llmSvc.GetName(), "-kserve-prefill-hpa")
|
|
}
|
|
|
|
func mainVAName(llmSvc *v1alpha2.LLMInferenceService) string {
|
|
return kmeta.ChildName(llmSvc.GetName(), "-kserve-va")
|
|
}
|
|
|
|
func prefillVAName(llmSvc *v1alpha2.LLMInferenceService) string {
|
|
return kmeta.ChildName(llmSvc.GetName(), "-kserve-prefill-va")
|
|
}
|
|
|
|
func mainScaledObjectName(llmSvc *v1alpha2.LLMInferenceService) string {
|
|
return kmeta.ChildName(llmSvc.GetName(), "-kserve-keda")
|
|
}
|
|
|
|
func prefillScaledObjectName(llmSvc *v1alpha2.LLMInferenceService) string {
|
|
return kmeta.ChildName(llmSvc.GetName(), "-kserve-prefill-keda")
|
|
}
|