diff --git a/deploy/helm/charts/platform/components/operator/templates/deployment.yaml b/deploy/helm/charts/platform/components/operator/templates/deployment.yaml index 483d9e381207..8a4ea7dd9080 100644 --- a/deploy/helm/charts/platform/components/operator/templates/deployment.yaml +++ b/deploy/helm/charts/platform/components/operator/templates/deployment.yaml @@ -92,12 +92,6 @@ spec: {{- with .Values.controllerManager.manager.dgdrDefaultImage }} - {{ printf "--dgdr-default-image=%s" . | quote }} {{- end }} - {{- with .Values.controllerManager.manager.dgdrAISimulateDefaultImage }} - - {{ printf "--dgdr-aisimulate-default-image=%s" . | quote }} - {{- end }} - {{- with .Values.controllerManager.manager.dgdrAISimulateDefaultVersionOverride }} - - {{ printf "--dgdr-aisimulate-default-version-override=%s" . | quote }} - {{- end }} command: - /manager env: diff --git a/deploy/helm/charts/platform/components/operator/values.yaml b/deploy/helm/charts/platform/components/operator/values.yaml index 007019cb491e..c526a93bebde 100644 --- a/deploy/helm/charts/platform/components/operator/values.yaml +++ b/deploy/helm/charts/platform/components/operator/values.yaml @@ -115,16 +115,6 @@ controllerManager: # derives dynamo-planner:, which only exists for GA # releases. dgdrDefaultImage: "" - # AISimulate runner image put into v1beta2 DGDR spec.aisimulate.image when unset. - # Empty derives nvcr.io/nvidia/ai-dynamo/dynamo-planner:. - dgdrAISimulateDefaultImage: "" - # AISimulate version bundled in the default image (for example 0.13.0), put into v1beta2 DGDR - # spec.aisimulate.versionOverride together with that image. Empty uses the AISimulate version - # the operator maps to the image's Dynamo release. Required when the image is not a mapped - # Dynamo release (for example nightly); the operator refuses to start without it. It also - # applies to the derived planner image when dgdrAISimulateDefaultImage is empty. A Request - # that names its own image never receives it. - dgdrAISimulateDefaultVersionOverride: "" resources: limits: cpu: 1024m diff --git a/deploy/operator/api/roundtrip_fuzz_test.go b/deploy/operator/api/roundtrip_fuzz_test.go index aa4089ef50ed..d111e14089a4 100644 --- a/deploy/operator/api/roundtrip_fuzz_test.go +++ b/deploy/operator/api/roundtrip_fuzz_test.go @@ -568,22 +568,21 @@ func fuzzBeta2Workload(workload *v1beta2.WorkloadSpec, c randfill.Continue) { } static := workload.Static - static.ISL = oneOfPtr(c, int32(1), int32(4000), int32(131072)) - static.OSL = oneOfPtr(c, int32(1), int32(1000), int32(16384)) - static.NumRequestRatio = oneOfPtr(c, 0.5, 1.0, 1000.0) - static.RequestRate = nil - static.Concurrency = nil - static.KVLoadRatio = nil + static.InputTokens = oneOfPtr(c, int32(1), int32(4000), int32(131072)) + static.OutputTokens = oneOfPtr(c, int32(1), int32(1000), int32(16384)) + static.Load = nil switch c.Intn(4) { case 1: - static.RequestRate = oneOfPtr(c, 0.1, 1.0, 100.0) + static.Load = &v1beta2.StaticLoadSpec{RequestsPerSecond: oneOfPtr(c, 0.1, 1.0, 100.0)} case 2: - static.Concurrency = oneOfPtr(c, int32(1), int32(32), int32(1024)) + static.Load = &v1beta2.StaticLoadSpec{Concurrency: oneOfPtr(c, int32(1), int32(32), int32(1024))} case 3: if c.Bool() { - static.KVLoadRatio = v1beta2.KVLoadRatio{0.5} + static.Load = &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{Value: new(0.5)}} } else { - static.KVLoadRatio = v1beta2.KVLoadRatio{0.0, 1.0} + static.Load = &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{ + Range: &v1beta2.KVCapacityFractionRange{Min: 0.1, Max: 1.0}, + }} } } } @@ -653,7 +652,7 @@ func isGoodputMetric(metric v1beta2.ObjectiveMetric) bool { return metric == "Goodput" || metric == "GoodputPerGPU" } -type fuzzAISimulateParameters struct { +type fuzzAISimulateInputOverrides struct { Search *fuzzAISimulateSearch `json:"search,omitempty"` Workload *fuzzAISimulateWorkload `json:"workload,omitempty"` } @@ -671,20 +670,20 @@ type fuzzAISimulateWorkload struct { func fuzzAISimulateSpec(s *v1beta2.AISimulateSpec, c randfill.Continue) { c.FillNoCustom(s) - // Exercise both absent and typed object parameter payloads. + // Exercise both absent and typed object input override payloads. if c.Bool() { - s.Parameters = nil + s.InputOverrides = nil return } // Marshal a typed test input instead of generating arbitrary RawExtension bytes. - parameters := &fuzzAISimulateParameters{} - c.Fill(parameters) - raw, err := json.Marshal(parameters) + overrides := &fuzzAISimulateInputOverrides{} + c.Fill(overrides) + raw, err := json.Marshal(overrides) if err != nil { panic(err) } - s.Parameters = &runtime.RawExtension{Raw: raw} + s.InputOverrides = &runtime.RawExtension{Raw: raw} } func marshalFuzzEnvelope(value any) *apiextensionsv1.JSON { diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go index b5755780954d..879107413c70 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go @@ -101,10 +101,9 @@ func TestV1Beta2RoundTripUsesEnvelope(t *testing.T) { }, Workload: &WorkloadSpec{ Static: &StaticWorkloadSpec{ - ISL: ptr.To(int32(4096)), - OSL: ptr.To(int32(1024)), - NumRequestRatio: ptr.To(2.0), - RequestRate: ptr.To(12.5), + InputTokens: ptr.To(int32(4096)), + OutputTokens: ptr.To(int32(1024)), + Load: &StaticLoadSpec{RequestsPerSecond: ptr.To(12.5)}, }, }, Objective: &ObjectiveSpec{ @@ -113,9 +112,7 @@ func TestV1Beta2RoundTripUsesEnvelope(t *testing.T) { SLA: &ObjectiveSLASpec{TTFTMs: ptr.To(2000.0), ITLMs: ptr.To(30.0)}, }, AISimulate: &AISimulateSpec{ - Image: "nvcr.io/nvidia/aisimulate:latest", - VersionOverride: "1.0.0", - Parameters: &runtime.RawExtension{Raw: []byte(`{"search_method":"grid"}`)}, + InputOverrides: &runtime.RawExtension{Raw: []byte(`{"search_method":"grid"}`)}, }, Recommendation: &RecommendationSpec{MaxCandidates: 3}, }, diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go index d7413eb3936a..b0dc3aad1683 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -64,67 +64,94 @@ type HardwareSpec struct { GPU *GPUHardwareSpec `json:"gpu,omitempty"` } -// KVLoadRatio is either a pinned load ratio or a minimum/maximum range. -// AISimulate performs mode-specific validation. -// +kubebuilder:validation:MinItems=1 -// +kubebuilder:validation:MaxItems=2 -// +kubebuilder:validation:items:Minimum=0 -// +kubebuilder:validation:items:Maximum=1 -// +kubebuilder:validation:XValidation:rule="size(self) != 2 || self[0] < self[1]",message="a range needs min < max" -type KVLoadRatio []float64 - -// StaticWorkloadSpec defines one of AISimulate's synthetic workload shapes. -// +kubebuilder:validation:XValidation:rule="(has(self.requestRate) ? 1 : 0) + (has(self.concurrency) ? 1 : 0) + (has(self.kvLoadRatio) ? 1 : 0) <= 1",message="at most one of requestRate, concurrency, or kvLoadRatio may be specified" -type StaticWorkloadSpec struct { - // ISL is the input sequence length in tokens. - // +optional - // +kubebuilder:validation:Minimum=1 - ISL *int32 `json:"isl,omitempty"` +// KVCapacityFractionRange defines the searched fraction of a candidate's KV capacity. +// +kubebuilder:validation:XValidation:rule="self.min < self.max",message="min must be less than max" +type KVCapacityFractionRange struct { + // Min is the lower end of the searched fraction range. + // +kubebuilder:validation:ExclusiveMinimum=true + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1 + Min float64 `json:"min"` - // OSL is the output sequence length in tokens. - // +optional - // +kubebuilder:validation:Minimum=1 - OSL *int32 `json:"osl,omitempty"` + // Max is the upper end of the searched fraction range. + // +kubebuilder:validation:ExclusiveMinimum=true + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1 + Max float64 `json:"max"` +} - // NumRequestRatio sets request count relative to the selected load. +// KVCapacityFractionSpec selects either one fixed fraction or a searched range. +// +kubebuilder:validation:XValidation:rule="(has(self.value) ? 1 : 0) + (has(self.range) ? 1 : 0) == 1",message="exactly one of value or range must be specified" +type KVCapacityFractionSpec struct { + // Value uses a fixed fraction of the candidate's KV capacity. A value of 1 + // uses its full estimated capacity. // +optional // +kubebuilder:validation:ExclusiveMinimum=true // +kubebuilder:validation:Minimum=0 - NumRequestRatio *float64 `json:"numRequestRatio,omitempty"` + // +kubebuilder:validation:Maximum=1 + Value *float64 `json:"value,omitempty"` + + // Range searches fractions between min and max. + // +optional + Range *KVCapacityFractionRange `json:"range,omitempty"` +} - // RequestRate is a fixed open-loop arrival rate in requests per second. +// StaticLoadSpec selects exactly one synthetic load model by field presence. +// +kubebuilder:validation:XValidation:rule="(has(self.requestsPerSecond) ? 1 : 0) + (has(self.concurrency) ? 1 : 0) + (has(self.kvCapacityFraction) ? 1 : 0) == 1",message="exactly one of requestsPerSecond, concurrency, or kvCapacityFraction must be specified" +type StaticLoadSpec struct { + // RequestsPerSecond is a fixed open-loop arrival rate. // +optional // +kubebuilder:validation:ExclusiveMinimum=true // +kubebuilder:validation:Minimum=0 - RequestRate *float64 `json:"requestRate,omitempty"` + RequestsPerSecond *float64 `json:"requestsPerSecond,omitempty"` // Concurrency is a fixed closed-loop in-flight request cap. // +optional // +kubebuilder:validation:Minimum=1 Concurrency *int32 `json:"concurrency,omitempty"` - // KVLoadRatio derives concurrency from candidate KV capacity. A two-value - // range requires a bounded AISimulate sweep. + // KVCapacityFraction derives concurrency from each candidate's estimated KV capacity. // +optional - KVLoadRatio KVLoadRatio `json:"kvLoadRatio,omitempty"` + KVCapacityFraction *KVCapacityFractionSpec `json:"kvCapacityFraction,omitempty"` +} + +// StaticWorkloadSpec defines AISimulate synthetic traffic. Admission completes a nonempty +// object with the omitted static defaults; an explicit empty object opts out of them. +type StaticWorkloadSpec struct { + // InputTokens is the synthetic request input length in tokens. + // +optional + // +kubebuilder:validation:Minimum=1 + InputTokens *int32 `json:"inputTokens,omitempty"` + + // OutputTokens is the synthetic request output length in tokens. + // +optional + // +kubebuilder:validation:Minimum=1 + OutputTokens *int32 `json:"outputTokens,omitempty"` + + // Load selects the synthetic load model. When omitted from a nonempty static + // workload, admission defaults it to concurrency 10. + // +optional + Load *StaticLoadSpec `json:"load,omitempty"` } // WorkloadSpec defines the synthetic traffic model used for every candidate evaluation. type WorkloadSpec struct { - // Static defines one of AISimulate's synthetic workload shapes. Its fields are not - // defaulted individually: the default belongs to spec.workload as a whole, so an - // explicit workload object keeps exactly the fields it names. + // Static defines AISimulate synthetic traffic. When omitted, admission supplies the + // complete static default. An explicit empty object opts out; any nonempty object is + // completed with omitted inputTokens, outputTokens and load defaults. // +optional Static *StaticWorkloadSpec `json:"static,omitempty"` } -// ObjectiveMode selects scalar optimization or a multi-objective Pareto search. -// +kubebuilder:validation:Enum=Optimize;Pareto +// ObjectiveMode selects the optimization mode. Pareto is reserved for a future +// release and is not admitted until Candidate publication supports Pareto fronts. +// +kubebuilder:validation:Enum=Optimize type ObjectiveMode string const ( ObjectiveModeOptimize ObjectiveMode = "Optimize" - ObjectiveModePareto ObjectiveMode = "Pareto" + // ObjectiveModePareto is reserved for the future Pareto API implementation. + ObjectiveModePareto ObjectiveMode = "Pareto" ) // ObjectiveMetric is a metric optimized by AISimulate. @@ -154,68 +181,57 @@ type ObjectiveSLASpec struct { E2EMs *float64 `json:"e2eMs,omitempty"` } -// ObjectiveSpec defines scalar optimization or a multi-objective Pareto search. -// +kubebuilder:validation:XValidation:rule="!has(self.mode) || (self.mode == 'Optimize' && !has(self.metrics)) || (self.mode == 'Pareto' && !has(self.metric) && has(self.metrics))",message="Optimize accepts only metric; Pareto requires metrics" +// ObjectiveSpec defines scalar optimization. Pareto fields are reserved for a future release. +// +kubebuilder:validation:XValidation:rule="!has(self.metrics)",message="metrics is reserved for the future Pareto implementation" // +kubebuilder:validation:XValidation:rule="!(has(self.metric) && self.metric in ['Goodput', 'GoodputPerGPU']) || (has(self.sla) && (has(self.sla.ttftMs) || has(self.sla.itlMs) || has(self.sla.e2eMs)))",message="Goodput and GoodputPerGPU require at least one SLA bound" -// +kubebuilder:validation:XValidation:rule="!(has(self.metrics) && self.metrics.exists(m, m in ['Goodput', 'GoodputPerGPU'])) || (has(self.sla) && (has(self.sla.ttftMs) || has(self.sla.itlMs) || has(self.sla.e2eMs)))",message="Pareto metrics containing Goodput or GoodputPerGPU require at least one SLA bound" type ObjectiveSpec struct { - // Mode selects scalar optimization or a Pareto search. + // Mode selects scalar optimization. Only Optimize is currently supported. // +optional Mode ObjectiveMode `json:"mode,omitempty"` // Metric is the scalar target when mode is Optimize. - // When omitted in Optimize mode, admission defaults it to Throughput. + // When omitted in Optimize mode, admission defaults it to ThroughputPerGPU. // +optional Metric *ObjectiveMetric `json:"metric,omitempty"` - // Metrics lists two or more distinct scalar targets when mode is Pareto. + // Metrics is reserved for the future Pareto implementation. // +optional // +kubebuilder:validation:MinItems=2 - // +kubebuilder:validation:MaxItems=7 + // +kubebuilder:validation:MaxItems=2 // +listType=set Metrics []ObjectiveMetric `json:"metrics,omitempty"` - // SLA defines per-request latency bounds used to calculate goodput. When omitted, no - // bounds apply. spec.aisimulate.parameters.goal.sla overrides the bounds it names. + // SLA defines per-request latency bounds used to calculate goodput. Admission defaults an + // omitted SLA to TTFT 2000 ms and ITL 30 ms. An explicit empty object opts out of those + // defaults. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. // +optional SLA *ObjectiveSLASpec `json:"sla,omitempty"` } -// AISimulateSpec selects the AISimulate execution artifact and supplies native input. +// AISimulateSpec supplies public recommendation input to the AISimulate version in the operator's configured +// planner image. type AISimulateSpec struct { - // Image selects a Dynamo-compatible AISimulate runner. When omitted, admission - // persists the operator's configured AISimulate image, or the Dynamo planner image of - // the operator's release, together with the AISimulate version it bundles. - // +optional - // +kubebuilder:validation:MinLength=1 - Image string `json:"image,omitempty"` - - // VersionOverride declares the AISimulate version the image bundles as three numeric parts - - // (for example 0.13.0), not a semver version: no v prefix, pre-release or build suffix. - - // When omitted, the version is the one the operator maps to the Dynamo release in the - - // image tag. It is required when the tag is not a Dynamo release the operator maps (a - - // nightly or custom image), and it wins over the mapping. - // +optional - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:Pattern=`^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$` - VersionOverride string `json:"versionOverride,omitempty"` - - // Parameters contains native AISimulate input. Field names and nesting use - // the upstream AISimulate JSON contract, including snake_case names. + // InputOverrides is an RFC 7386 JSON Merge Patch over the input lowered from + // the typed fields. It uses the upstream AISimulate JSON contract, including + // snake_case names. Values here take precedence, and null deletes a lowered + // field. The dgdr_run member must remain an object, and its operator-owned + // transport, identity, and runtime fields name, max_candidates, snapshot_dir, + // runtime_image, runtime_version_override, derive_runtime_images, + // num_gpus_per_node, and namespace are operator-owned. Request admission + // rejects them, and the Run controller enforces its values while lowering the + // immutable snapshot. The operator runs the AISimulate version from its + // configured planner image; Requests and Runs cannot select a different image + // or version. // +optional // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Type=object - Parameters *runtime.RawExtension `json:"parameters,omitempty"` + InputOverrides *runtime.RawExtension `json:"inputOverrides,omitempty"` } // RecommendationSpec controls bounded projection into DGDC resources. type RecommendationSpec struct { // MaxCandidates is the maximum number of DGDCs owned by one run. Scalar - // candidates are ordered best first; Pareto candidates have no scalar rank. + // candidates are ordered best first. // This field does not limit internal trials. // +optional // +kubebuilder:validation:Minimum=1 @@ -242,23 +258,23 @@ type NativeSearchInput struct { Hardware *HardwareSpec `json:"hardware,omitempty"` // Workload defines the synthetic traffic model used for every candidate evaluation. - // When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; - // an explicit empty workload receives the same shape from admission. An explicit - // workload.static: {} opts out of that shape, leaving the workload to - // spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the - // fields it names. - // The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a - // KV-load ratio search. + // When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; + // an explicit empty workload receives the same shape. workload.static: {} opts out of all + // traffic defaults. Any other static object is completed with its missing defaults. + // spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over + // every lowered traffic field it names. + // The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction + // search. Lowering independently defaults optimizer.max_trials to 320. // +optional Workload *WorkloadSpec `json:"workload,omitempty"` - // Objective defines scalar optimization or a multi-objective Pareto search. - // When omitted, admission supplies the object. Its mode defaults to Optimize, and admission - // supplies the conditional scalar metric. + // Objective defines scalar optimization. When omitted, + // admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults + // to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. // +optional Objective *ObjectiveSpec `json:"objective,omitempty"` - // AISimulate selects the execution artifact and supplies native AISimulate input. + // AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. // +optional AISimulate *AISimulateSpec `json:"aisimulate,omitempty"` diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrun_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrun_types.go index 692520aab96b..280a487756b4 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrun_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrun_types.go @@ -20,6 +20,7 @@ package v1beta2 import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ) @@ -119,6 +120,17 @@ type DGDRRunProgress struct { Candidates *CandidateProgress `json:"candidates,omitempty"` } +// AISimulateRunStatus reports the exact public recommendation input used for this execution. +type AISimulateRunStatus struct { + // Input is the fully lowered AISimulate input after typed fields, derived defaults and + // spec.aisimulate.inputOverrides have been merged. Field names and nesting use the + // upstream AISimulate JSON contract. + // +optional + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Type=object + Input *runtime.RawExtension `json:"input,omitempty"` +} + // DynamoGraphDeploymentRunStatus represents one search execution's observed state. type DynamoGraphDeploymentRunStatus struct { // Phase is a concise presentation state. Conditions remain authoritative. @@ -133,6 +145,10 @@ type DynamoGraphDeploymentRunStatus struct { // +optional Progress *DGDRRunProgress `json:"progress,omitempty"` + // AISimulate reports the effective input executed by AISimulate. + // +optional + AISimulate *AISimulateRunStatus `json:"aisimulate,omitempty"` + // CandidateRefs identifies the current bounded DGDC projection. List order // represents rank, best first, for scalar searches. // +optional diff --git a/deploy/operator/api/v1beta2/zz_generated.deepcopy.go b/deploy/operator/api/v1beta2/zz_generated.deepcopy.go index 2188565147ed..fe68ab15bfa9 100644 --- a/deploy/operator/api/v1beta2/zz_generated.deepcopy.go +++ b/deploy/operator/api/v1beta2/zz_generated.deepcopy.go @@ -44,11 +44,31 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AISimulateRunStatus) DeepCopyInto(out *AISimulateRunStatus) { + *out = *in + if in.Input != nil { + in, out := &in.Input, &out.Input + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AISimulateRunStatus. +func (in *AISimulateRunStatus) DeepCopy() *AISimulateRunStatus { + if in == nil { + return nil + } + out := new(AISimulateRunStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AISimulateSpec) DeepCopyInto(out *AISimulateSpec) { *out = *in - if in.Parameters != nil { - in, out := &in.Parameters, &out.Parameters + if in.InputOverrides != nil { + in, out := &in.InputOverrides, &out.InputOverrides *out = new(runtime.RawExtension) (*in).DeepCopyInto(*out) } @@ -458,6 +478,11 @@ func (in *DynamoGraphDeploymentRunStatus) DeepCopyInto(out *DynamoGraphDeploymen *out = new(DGDRRunProgress) (*in).DeepCopyInto(*out) } + if in.AISimulate != nil { + in, out := &in.AISimulate, &out.AISimulate + *out = new(AISimulateRunStatus) + (*in).DeepCopyInto(*out) + } if in.CandidateRefs != nil { in, out := &in.CandidateRefs, &out.CandidateRefs *out = make([]corev1.LocalObjectReference, len(*in)) @@ -555,22 +580,43 @@ func (in *HardwareSpec) DeepCopy() *HardwareSpec { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in KVLoadRatio) DeepCopyInto(out *KVLoadRatio) { - { - in := &in - *out = make(KVLoadRatio, len(*in)) - copy(*out, *in) +func (in *KVCapacityFractionRange) DeepCopyInto(out *KVCapacityFractionRange) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVCapacityFractionRange. +func (in *KVCapacityFractionRange) DeepCopy() *KVCapacityFractionRange { + if in == nil { + return nil + } + out := new(KVCapacityFractionRange) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KVCapacityFractionSpec) DeepCopyInto(out *KVCapacityFractionSpec) { + *out = *in + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(float64) + **out = **in + } + if in.Range != nil { + in, out := &in.Range, &out.Range + *out = new(KVCapacityFractionRange) + **out = **in } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVLoadRatio. -func (in KVLoadRatio) DeepCopy() KVLoadRatio { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KVCapacityFractionSpec. +func (in *KVCapacityFractionSpec) DeepCopy() *KVCapacityFractionSpec { if in == nil { return nil } - out := new(KVLoadRatio) + out := new(KVCapacityFractionSpec) in.DeepCopyInto(out) - return *out + return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -744,37 +790,52 @@ func (in *RoundProgress) DeepCopy() *RoundProgress { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *StaticWorkloadSpec) DeepCopyInto(out *StaticWorkloadSpec) { +func (in *StaticLoadSpec) DeepCopyInto(out *StaticLoadSpec) { *out = *in - if in.ISL != nil { - in, out := &in.ISL, &out.ISL - *out = new(int32) + if in.RequestsPerSecond != nil { + in, out := &in.RequestsPerSecond, &out.RequestsPerSecond + *out = new(float64) **out = **in } - if in.OSL != nil { - in, out := &in.OSL, &out.OSL + if in.Concurrency != nil { + in, out := &in.Concurrency, &out.Concurrency *out = new(int32) **out = **in } - if in.NumRequestRatio != nil { - in, out := &in.NumRequestRatio, &out.NumRequestRatio - *out = new(float64) - **out = **in + if in.KVCapacityFraction != nil { + in, out := &in.KVCapacityFraction, &out.KVCapacityFraction + *out = new(KVCapacityFractionSpec) + (*in).DeepCopyInto(*out) } - if in.RequestRate != nil { - in, out := &in.RequestRate, &out.RequestRate - *out = new(float64) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StaticLoadSpec. +func (in *StaticLoadSpec) DeepCopy() *StaticLoadSpec { + if in == nil { + return nil + } + out := new(StaticLoadSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StaticWorkloadSpec) DeepCopyInto(out *StaticWorkloadSpec) { + *out = *in + if in.InputTokens != nil { + in, out := &in.InputTokens, &out.InputTokens + *out = new(int32) **out = **in } - if in.Concurrency != nil { - in, out := &in.Concurrency, &out.Concurrency + if in.OutputTokens != nil { + in, out := &in.OutputTokens, &out.OutputTokens *out = new(int32) **out = **in } - if in.KVLoadRatio != nil { - in, out := &in.KVLoadRatio, &out.KVLoadRatio - *out = make(KVLoadRatio, len(*in)) - copy(*out, *in) + if in.Load != nil { + in, out := &in.Load, &out.Load + *out = new(StaticLoadSpec) + (*in).DeepCopyInto(*out) } } diff --git a/deploy/operator/cmd/main.go b/deploy/operator/cmd/main.go index c890ba7bcd2a..078e66528ef3 100644 --- a/deploy/operator/cmd/main.go +++ b/deploy/operator/cmd/main.go @@ -161,8 +161,6 @@ func main() { var operatorImage string var operatorImagePullPolicy string var dgdrDefaultImage string - var dgdrAISimulateDefaultImage string - var dgdrAISimulateDefaultVersionOverride string flag.StringVar(&configFile, "config", "", "Path to operator configuration file (required)") flag.StringVar(&operatorVersion, "operator-version", "unknown", "Version of the operator (used in lease holder identity)") @@ -176,13 +174,6 @@ func main() { "Image pull policy for operator helper init containers") flag.StringVar(&dgdrDefaultImage, "dgdr-default-image", "", "Default DGDR profiler image, put into DGDR spec.image when unset; empty derives dynamo-planner:") - flag.StringVar(&dgdrAISimulateDefaultImage, "dgdr-aisimulate-default-image", "", - "AISimulate runner image, put into v1beta2 DGDR spec.aisimulate.image when unset; "+ - "empty derives dynamo-planner:") - flag.StringVar(&dgdrAISimulateDefaultVersionOverride, "dgdr-aisimulate-default-version-override", "", - "AISimulate version bundled in the AISimulate default image, put into v1beta2 DGDR "+ - "spec.aisimulate.versionOverride together with that image; empty uses the version the release "+ - "contract maps to the image's Dynamo release, and is required for an image without one") opts := zap.Options{ Development: true, } @@ -190,13 +181,6 @@ func main() { flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - if err := webhookdefaulting.ValidateAISimulateDefault( - operatorVersion, dgdrAISimulateDefaultImage, dgdrAISimulateDefaultVersionOverride, - ); err != nil { - setupLog.Error(err, "invalid AISimulate default") - os.Exit(1) - } - if configFile == "" { setupLog.Error(nil, "--config flag is required") os.Exit(1) @@ -522,8 +506,7 @@ func main() { } if err := registerWebhookHandlers( - mgr, operatorCfg, runtimeConfig, operatorVersion, dgdrDefaultImage, dgdrAISimulateDefaultImage, - dgdrAISimulateDefaultVersionOverride, gates, + mgr, operatorCfg, runtimeConfig, operatorVersion, dgdrDefaultImage, gates, ); err != nil { setupLog.Error(err, "failed to register webhooks") os.Exit(1) @@ -663,8 +646,6 @@ func registerWebhookHandlers( runtimeConfig *commonController.RuntimeConfig, operatorVersion string, dgdrDefaultImage string, - dgdrAISimulateDefaultImage string, - dgdrAISimulateDefaultVersionOverride string, gate features.Gate, ) error { var operatorPrincipal string @@ -683,8 +664,6 @@ func registerWebhookHandlers( DefaultImage: dgdrDefaultImage, }, DGDRV1beta2: webhookdefaulting.DGDRV1beta2Options{ - AISimulateImage: dgdrAISimulateDefaultImage, - AISimulateVersionOverride: dgdrAISimulateDefaultVersionOverride, // The same discovery order as the DGDR reconciler: DCGM first, then node labels when // the GPUDiscovery gate is on. The webhook keeps its own short-lived cache. Hardware: &gpu.Discoverer{ diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml index 9f5cb6b112b7..03339360d9b6 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9589,26 +9589,23 @@ spec: Native v1beta2 fields and the V1Beta1 conversion envelope are mutually exclusive. properties: aisimulate: - description: AISimulate selects the execution artifact and supplies native AISimulate input. + description: AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. properties: - image: + inputOverrides: description: |- - Image selects a Dynamo-compatible AISimulate runner. When omitted, admission - persists the operator's configured AISimulate image, or the Dynamo planner image of - the operator's release, together with the AISimulate version it bundles. - minLength: 1 - type: string - parameters: - description: |- - Parameters contains native AISimulate input. Field names and nesting use - the upstream AISimulate JSON contract, including snake_case names. + InputOverrides is an RFC 7386 JSON Merge Patch over the input lowered from + the typed fields. It uses the upstream AISimulate JSON contract, including + snake_case names. Values here take precedence, and null deletes a lowered + field. The dgdr_run member must remain an object, and its operator-owned + transport, identity, and runtime fields name, max_candidates, snapshot_dir, + runtime_image, runtime_version_override, derive_runtime_images, + num_gpus_per_node, and namespace are operator-owned. Request admission + rejects them, and the Run controller enforces its values while lowering the + immutable snapshot. The operator runs the AISimulate version from its + configured planner image; Requests and Runs cannot select a different image + or version. type: object x-kubernetes-preserve-unknown-fields: true - versionOverride: - description: nightly or custom image), and it wins over the mapping. - minLength: 1 - pattern: ^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$ - type: string type: object backends: description: Backends lists one or more inference backends searched by AISimulate and used by generated candidates. @@ -9659,14 +9656,14 @@ spec: type: object objective: description: |- - Objective defines scalar optimization or a multi-objective Pareto search. - When omitted, admission supplies the object. Its mode defaults to Optimize, and admission - supplies the conditional scalar metric. + Objective defines scalar optimization. When omitted, + admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults + to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. properties: metric: description: |- Metric is the scalar target when mode is Optimize. - When omitted in Optimize mode, admission defaults it to Throughput. + When omitted in Optimize mode, admission defaults it to ThroughputPerGPU. enum: - Throughput - ThroughputPerGPU @@ -9677,7 +9674,7 @@ spec: - GoodputPerGPU type: string metrics: - description: Metrics lists two or more distinct scalar targets when mode is Pareto. + description: Metrics is reserved for the future Pareto implementation. items: description: ObjectiveMetric is a metric optimized by AISimulate. enum: @@ -9689,20 +9686,20 @@ spec: - Goodput - GoodputPerGPU type: string - maxItems: 7 + maxItems: 2 minItems: 2 type: array x-kubernetes-list-type: set mode: - description: Mode selects scalar optimization or a Pareto search. + description: Mode selects scalar optimization. Only Optimize is currently supported. enum: - Optimize - - Pareto type: string sla: description: |- - SLA defines per-request latency bounds used to calculate goodput. When omitted, no - bounds apply. spec.aisimulate.parameters.goal.sla overrides the bounds it names. + SLA defines per-request latency bounds used to calculate goodput. Admission defaults an + omitted SLA to TTFT 2000 ms and ITL 30 ms. An explicit empty object opts out of those + defaults. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. properties: e2eMs: description: E2EMs is an alternative end-to-end bound in milliseconds. @@ -9725,19 +9722,17 @@ spec: rule: '!has(self.e2eMs) || (!has(self.ttftMs) && !has(self.itlMs))' type: object x-kubernetes-validations: - - message: Optimize accepts only metric; Pareto requires metrics - rule: '!has(self.mode) || (self.mode == ''Optimize'' && !has(self.metrics)) || (self.mode == ''Pareto'' && !has(self.metric) && has(self.metrics))' + - message: metrics is reserved for the future Pareto implementation + rule: '!has(self.metrics)' - message: Goodput and GoodputPerGPU require at least one SLA bound rule: '!(has(self.metric) && self.metric in [''Goodput'', ''GoodputPerGPU'']) || (has(self.sla) && (has(self.sla.ttftMs) || has(self.sla.itlMs) || has(self.sla.e2eMs)))' - - message: Pareto metrics containing Goodput or GoodputPerGPU require at least one SLA bound - rule: '!(has(self.metrics) && self.metrics.exists(m, m in [''Goodput'', ''GoodputPerGPU''])) || (has(self.sla) && (has(self.sla.ttftMs) || has(self.sla.itlMs) || has(self.sla.e2eMs)))' recommendation: description: Recommendation controls bounded projection into DGDC resources. properties: maxCandidates: description: |- MaxCandidates is the maximum number of DGDCs owned by one run. Scalar - candidates are ordered best first; Pareto candidates have no scalar rank. + candidates are ordered best first. This field does not limit internal trials. format: int32 maximum: 100 @@ -9755,63 +9750,87 @@ spec: workload: description: |- Workload defines the synthetic traffic model used for every candidate evaluation. - When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; - an explicit empty workload receives the same shape from admission. An explicit - workload.static: {} opts out of that shape, leaving the workload to - spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the - fields it names. - The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a - KV-load ratio search. + When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; + an explicit empty workload receives the same shape. workload.static: {} opts out of all + traffic defaults. Any other static object is completed with its missing defaults. + spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over + every lowered traffic field it names. + The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction + search. Lowering independently defaults optimizer.max_trials to 320. properties: static: description: |- - Static defines one of AISimulate's synthetic workload shapes. Its fields are not - defaulted individually: the default belongs to spec.workload as a whole, so an - explicit workload object keeps exactly the fields it names. + Static defines AISimulate synthetic traffic. When omitted, admission supplies the + complete static default. An explicit empty object opts out; any nonempty object is + completed with omitted inputTokens, outputTokens and load defaults. properties: - concurrency: - description: Concurrency is a fixed closed-loop in-flight request cap. + inputTokens: + description: InputTokens is the synthetic request input length in tokens. format: int32 minimum: 1 type: integer - isl: - description: ISL is the input sequence length in tokens. - format: int32 - minimum: 1 - type: integer - kvLoadRatio: + load: description: |- - KVLoadRatio derives concurrency from candidate KV capacity. A two-value - range requires a bounded AISimulate sweep. - items: - maximum: 1 - minimum: 0 - type: number - maxItems: 2 - minItems: 1 - type: array + Load selects the synthetic load model. When omitted from a nonempty static + workload, admission defaults it to concurrency 10. + properties: + concurrency: + description: Concurrency is a fixed closed-loop in-flight request cap. + format: int32 + minimum: 1 + type: integer + kvCapacityFraction: + description: KVCapacityFraction derives concurrency from each candidate's estimated KV capacity. + properties: + range: + description: Range searches fractions between min and max. + properties: + max: + description: Max is the upper end of the searched fraction range. + exclusiveMinimum: true + maximum: 1 + minimum: 0 + type: number + min: + description: Min is the lower end of the searched fraction range. + exclusiveMinimum: true + maximum: 1 + minimum: 0 + type: number + required: + - max + - min + type: object + x-kubernetes-validations: + - message: min must be less than max + rule: self.min < self.max + value: + description: |- + Value uses a fixed fraction of the candidate's KV capacity. A value of 1 + uses its full estimated capacity. + exclusiveMinimum: true + maximum: 1 + minimum: 0 + type: number + type: object + x-kubernetes-validations: + - message: exactly one of value or range must be specified + rule: '(has(self.value) ? 1 : 0) + (has(self.range) ? 1 : 0) == 1' + requestsPerSecond: + description: RequestsPerSecond is a fixed open-loop arrival rate. + exclusiveMinimum: true + minimum: 0 + type: number + type: object x-kubernetes-validations: - - message: a range needs min < max - rule: size(self) != 2 || self[0] < self[1] - numRequestRatio: - description: NumRequestRatio sets request count relative to the selected load. - exclusiveMinimum: true - minimum: 0 - type: number - osl: - description: OSL is the output sequence length in tokens. + - message: exactly one of requestsPerSecond, concurrency, or kvCapacityFraction must be specified + rule: '(has(self.requestsPerSecond) ? 1 : 0) + (has(self.concurrency) ? 1 : 0) + (has(self.kvCapacityFraction) ? 1 : 0) == 1' + outputTokens: + description: OutputTokens is the synthetic request output length in tokens. format: int32 minimum: 1 type: integer - requestRate: - description: RequestRate is a fixed open-loop arrival rate in requests per second. - exclusiveMinimum: true - minimum: 0 - type: number type: object - x-kubernetes-validations: - - message: at most one of requestRate, concurrency, or kvLoadRatio may be specified - rule: '(has(self.requestRate) ? 1 : 0) + (has(self.concurrency) ? 1 : 0) + (has(self.kvLoadRatio) ? 1 : 0) <= 1' type: object type: object x-kubernetes-validations: diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml index 8218fea7e98f..be81839490ba 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -61,26 +61,23 @@ spec: Run creators resolve optional Request inputs before persisting the Run. properties: aisimulate: - description: AISimulate selects the execution artifact and supplies native AISimulate input. + description: AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. properties: - image: + inputOverrides: description: |- - Image selects a Dynamo-compatible AISimulate runner. When omitted, admission - persists the operator's configured AISimulate image, or the Dynamo planner image of - the operator's release, together with the AISimulate version it bundles. - minLength: 1 - type: string - parameters: - description: |- - Parameters contains native AISimulate input. Field names and nesting use - the upstream AISimulate JSON contract, including snake_case names. + InputOverrides is an RFC 7386 JSON Merge Patch over the input lowered from + the typed fields. It uses the upstream AISimulate JSON contract, including + snake_case names. Values here take precedence, and null deletes a lowered + field. The dgdr_run member must remain an object, and its operator-owned + transport, identity, and runtime fields name, max_candidates, snapshot_dir, + runtime_image, runtime_version_override, derive_runtime_images, + num_gpus_per_node, and namespace are operator-owned. Request admission + rejects them, and the Run controller enforces its values while lowering the + immutable snapshot. The operator runs the AISimulate version from its + configured planner image; Requests and Runs cannot select a different image + or version. type: object x-kubernetes-preserve-unknown-fields: true - versionOverride: - description: nightly or custom image), and it wins over the mapping. - minLength: 1 - pattern: ^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$ - type: string type: object backends: description: Backends lists one or more inference backends searched by AISimulate and used by generated candidates. @@ -131,14 +128,14 @@ spec: type: object objective: description: |- - Objective defines scalar optimization or a multi-objective Pareto search. - When omitted, admission supplies the object. Its mode defaults to Optimize, and admission - supplies the conditional scalar metric. + Objective defines scalar optimization. When omitted, + admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults + to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. properties: metric: description: |- Metric is the scalar target when mode is Optimize. - When omitted in Optimize mode, admission defaults it to Throughput. + When omitted in Optimize mode, admission defaults it to ThroughputPerGPU. enum: - Throughput - ThroughputPerGPU @@ -149,7 +146,7 @@ spec: - GoodputPerGPU type: string metrics: - description: Metrics lists two or more distinct scalar targets when mode is Pareto. + description: Metrics is reserved for the future Pareto implementation. items: description: ObjectiveMetric is a metric optimized by AISimulate. enum: @@ -161,20 +158,20 @@ spec: - Goodput - GoodputPerGPU type: string - maxItems: 7 + maxItems: 2 minItems: 2 type: array x-kubernetes-list-type: set mode: - description: Mode selects scalar optimization or a Pareto search. + description: Mode selects scalar optimization. Only Optimize is currently supported. enum: - Optimize - - Pareto type: string sla: description: |- - SLA defines per-request latency bounds used to calculate goodput. When omitted, no - bounds apply. spec.aisimulate.parameters.goal.sla overrides the bounds it names. + SLA defines per-request latency bounds used to calculate goodput. Admission defaults an + omitted SLA to TTFT 2000 ms and ITL 30 ms. An explicit empty object opts out of those + defaults. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. properties: e2eMs: description: E2EMs is an alternative end-to-end bound in milliseconds. @@ -197,19 +194,17 @@ spec: rule: '!has(self.e2eMs) || (!has(self.ttftMs) && !has(self.itlMs))' type: object x-kubernetes-validations: - - message: Optimize accepts only metric; Pareto requires metrics - rule: '!has(self.mode) || (self.mode == ''Optimize'' && !has(self.metrics)) || (self.mode == ''Pareto'' && !has(self.metric) && has(self.metrics))' + - message: metrics is reserved for the future Pareto implementation + rule: '!has(self.metrics)' - message: Goodput and GoodputPerGPU require at least one SLA bound rule: '!(has(self.metric) && self.metric in [''Goodput'', ''GoodputPerGPU'']) || (has(self.sla) && (has(self.sla.ttftMs) || has(self.sla.itlMs) || has(self.sla.e2eMs)))' - - message: Pareto metrics containing Goodput or GoodputPerGPU require at least one SLA bound - rule: '!(has(self.metrics) && self.metrics.exists(m, m in [''Goodput'', ''GoodputPerGPU''])) || (has(self.sla) && (has(self.sla.ttftMs) || has(self.sla.itlMs) || has(self.sla.e2eMs)))' recommendation: description: Recommendation controls bounded projection into DGDC resources. properties: maxCandidates: description: |- MaxCandidates is the maximum number of DGDCs owned by one run. Scalar - candidates are ordered best first; Pareto candidates have no scalar rank. + candidates are ordered best first. This field does not limit internal trials. format: int32 maximum: 100 @@ -219,63 +214,87 @@ spec: workload: description: |- Workload defines the synthetic traffic model used for every candidate evaluation. - When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; - an explicit empty workload receives the same shape from admission. An explicit - workload.static: {} opts out of that shape, leaving the workload to - spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the - fields it names. - The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a - KV-load ratio search. + When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; + an explicit empty workload receives the same shape. workload.static: {} opts out of all + traffic defaults. Any other static object is completed with its missing defaults. + spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over + every lowered traffic field it names. + The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction + search. Lowering independently defaults optimizer.max_trials to 320. properties: static: description: |- - Static defines one of AISimulate's synthetic workload shapes. Its fields are not - defaulted individually: the default belongs to spec.workload as a whole, so an - explicit workload object keeps exactly the fields it names. + Static defines AISimulate synthetic traffic. When omitted, admission supplies the + complete static default. An explicit empty object opts out; any nonempty object is + completed with omitted inputTokens, outputTokens and load defaults. properties: - concurrency: - description: Concurrency is a fixed closed-loop in-flight request cap. + inputTokens: + description: InputTokens is the synthetic request input length in tokens. format: int32 minimum: 1 type: integer - isl: - description: ISL is the input sequence length in tokens. - format: int32 - minimum: 1 - type: integer - kvLoadRatio: + load: description: |- - KVLoadRatio derives concurrency from candidate KV capacity. A two-value - range requires a bounded AISimulate sweep. - items: - maximum: 1 - minimum: 0 - type: number - maxItems: 2 - minItems: 1 - type: array + Load selects the synthetic load model. When omitted from a nonempty static + workload, admission defaults it to concurrency 10. + properties: + concurrency: + description: Concurrency is a fixed closed-loop in-flight request cap. + format: int32 + minimum: 1 + type: integer + kvCapacityFraction: + description: KVCapacityFraction derives concurrency from each candidate's estimated KV capacity. + properties: + range: + description: Range searches fractions between min and max. + properties: + max: + description: Max is the upper end of the searched fraction range. + exclusiveMinimum: true + maximum: 1 + minimum: 0 + type: number + min: + description: Min is the lower end of the searched fraction range. + exclusiveMinimum: true + maximum: 1 + minimum: 0 + type: number + required: + - max + - min + type: object + x-kubernetes-validations: + - message: min must be less than max + rule: self.min < self.max + value: + description: |- + Value uses a fixed fraction of the candidate's KV capacity. A value of 1 + uses its full estimated capacity. + exclusiveMinimum: true + maximum: 1 + minimum: 0 + type: number + type: object + x-kubernetes-validations: + - message: exactly one of value or range must be specified + rule: '(has(self.value) ? 1 : 0) + (has(self.range) ? 1 : 0) == 1' + requestsPerSecond: + description: RequestsPerSecond is a fixed open-loop arrival rate. + exclusiveMinimum: true + minimum: 0 + type: number + type: object x-kubernetes-validations: - - message: a range needs min < max - rule: size(self) != 2 || self[0] < self[1] - numRequestRatio: - description: NumRequestRatio sets request count relative to the selected load. - exclusiveMinimum: true - minimum: 0 - type: number - osl: - description: OSL is the output sequence length in tokens. + - message: exactly one of requestsPerSecond, concurrency, or kvCapacityFraction must be specified + rule: '(has(self.requestsPerSecond) ? 1 : 0) + (has(self.concurrency) ? 1 : 0) + (has(self.kvCapacityFraction) ? 1 : 0) == 1' + outputTokens: + description: OutputTokens is the synthetic request output length in tokens. format: int32 minimum: 1 type: integer - requestRate: - description: RequestRate is a fixed open-loop arrival rate in requests per second. - exclusiveMinimum: true - minimum: 0 - type: number type: object - x-kubernetes-validations: - - message: at most one of requestRate, concurrency, or kvLoadRatio may be specified - rule: '(has(self.requestRate) ? 1 : 0) + (has(self.concurrency) ? 1 : 0) + (has(self.kvLoadRatio) ? 1 : 0) <= 1' type: object type: object x-kubernetes-validations: @@ -284,6 +303,17 @@ spec: status: description: DynamoGraphDeploymentRunStatus represents one search execution's observed state. properties: + aisimulate: + description: AISimulate reports the effective input executed by AISimulate. + properties: + input: + description: |- + Input is the fully lowered AISimulate input after typed fields, derived defaults and + spec.aisimulate.inputOverrides have been merged. Field names and nesting use the + upstream AISimulate JSON contract. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object candidateRefs: description: |- CandidateRefs identifies the current bounded DGDC projection. List order diff --git a/deploy/operator/internal/aisimulate/defaults.go b/deploy/operator/internal/aisimulate/defaults.go new file mode 100644 index 000000000000..d26bbd54fea5 --- /dev/null +++ b/deploy/operator/internal/aisimulate/defaults.go @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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 aisimulate + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +// The derived defaults. No API field carries them: each depends on the typed underlay, so +// no single typed field can supply it. +const ( + // DefaultRequestCount is traffic.stop.requests for every native static workload. + // Alternative stop policies belong entirely to inputOverrides, which must delete + // this value when replacing it. + DefaultRequestCount int64 = 1000 + + // DefaultMaxTrials bounds every search that has no explicit optimizer.max_trials. + // It matches the trial budget of AISimulate's unified CLI while making the + // effective input persisted in Run status self-contained. + DefaultMaxTrials int64 = 320 +) + +// publicUnderlay supplies the role objects required by AISimulate's default +// aggregated-and-disaggregated engine search. Empty workers retain the upstream +// parallelism, scheduler, KV-cache and timing defaults. +func publicUnderlay() map[string]any { + return map[string]any{ + "engine": map[string]any{ + "workers": map[string]any{ + "aggregated": map[string]any{}, + "prefill": map[string]any{}, + "decode": map[string]any{}, + }, + }, + } +} + +// applyDefaults adds the derived defaults to the typed underlay, before the public-input +// input overrides are applied. spec.aisimulate.inputOverrides is the final overlay: it overrides or +// deletes any derived value, and nothing is derived in response to what it introduces. +// An override that needs a matching change elsewhere (deleting requests when adding +// requests_per_load_unit, or strict_sla next to a new SLA bound) must make that change itself. +func applyDefaults(underlay map[string]any) error { + b := builder{root: underlay} + + if has(underlay, trafficPath...) { + if !has(underlay, trafficStopRequests...) { + b.set(float64(DefaultRequestCount), trafficStopRequests) + } + } + if !has(underlay, optimizerMaxTrials...) { + b.set(float64(DefaultMaxTrials), optimizerMaxTrials) + } + + if hasSLABound(underlay) && !has(underlay, optimizationStrictSLA...) { + b.set(true, optimizationStrictSLA) + } + return b.err +} + +// hasSLABound reports whether the input bounds at least one latency. +func hasSLABound(input map[string]any) bool { + for _, path := range [][]string{evaluationSLATTFTMs, evaluationSLAITLMs, evaluationSLAE2EMs} { + if bound, _, _ := unstructured.NestedFieldNoCopy(input, path...); bound != nil { + return true + } + } + return false +} diff --git a/deploy/operator/internal/aisimulate/doc.go b/deploy/operator/internal/aisimulate/doc.go new file mode 100644 index 000000000000..a41ef4bee3d7 --- /dev/null +++ b/deploy/operator/internal/aisimulate/doc.go @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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 aisimulate lowers a v1beta2 NativeSearchInput snapshot into the public +// AISimulate recommendation input consumed by `aisimulate recommend --stack dynamo`. +// +// Admission and reconciliation use the same lowering. Admission runs it after structural +// validation so malformed overrides and unmappable typed values fail before persistence. The +// Request reconciler snapshots the accepted input into an immutable Run. The Run reconciler +// lowers that snapshot once, records the result in status.aisimulate.input and passes the same +// document to the Job. +// +// Lower starts with the public engine-role underlay required for AISimulate's default +// aggregated-and-disaggregated search. It then translates the typed spec (model, backends, +// hardware, workload, objective) into that input. The derived defaults that no API field +// carries are added next: the request count, a bounded trial budget for a +// KV-load range search, and optimization.strict_sla when an SLA bound is set. Last, +// spec.aisimulate.inputOverrides is applied on top as a JSON Merge Patch (RFC 7386): upstream +// objects merge recursively, arrays and scalars replace the value, and null deletes it. The +// input overrides are authoritative: they win on every path they name, including the derived +// defaults, and are applied once, last. +// +// The document stays unstructured (map[string]any) because its schema belongs to the +// AISimulate release bundled in the operator's Planner image, not to this package. Reads and writes go +// through the helpers of k8s.io/apimachinery/pkg/apis/meta/v1/unstructured. +package aisimulate diff --git a/deploy/operator/internal/aisimulate/input.go b/deploy/operator/internal/aisimulate/input.go new file mode 100644 index 000000000000..54d42bc1c679 --- /dev/null +++ b/deploy/operator/internal/aisimulate/input.go @@ -0,0 +1,293 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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 aisimulate + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + + v1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Lower returns the effective public recommendation input: the typed underlay and derived defaults, merged +// with spec.aisimulate.inputOverrides. The spec must already carry its structural defaults; +// lowering repeats none of them. +func Lower(spec *v1beta2.NativeSearchInput) (map[string]any, error) { + return LowerWithUnderlay(spec, nil) +} + +// LowerWithUnderlay returns the effective public recommendation input with a controller-owned +// static underlay beneath the typed API fields and derived defaults. InputOverrides remains the +// final merge patch, so it may override or delete fields from either underlay. +func LowerWithUnderlay(spec *v1beta2.NativeSearchInput, staticUnderlay map[string]any) (map[string]any, error) { + overrides, err := InputOverrides(spec) + if err != nil { + return nil, err + } + typedUnderlay, err := Underlay(spec) + if err != nil { + return nil, err + } + staticUnderlay, err = normalize(staticUnderlay) + if err != nil { + return nil, err + } + underlay := publicUnderlay() + underlay, _ = mergePatch(underlay, staticUnderlay).(map[string]any) + underlay, _ = mergePatch(underlay, typedUnderlay).(map[string]any) + if underlay, err = normalize(underlay); err != nil { + return nil, err + } + if err := applyDefaults(underlay); err != nil { + return nil, err + } + input, _ := mergePatch(underlay, overrides).(map[string]any) + if input == nil { + input = map[string]any{} + } + return input, nil +} + +// InputOverrides returns spec.aisimulate.inputOverrides as a JSON object, or nil when absent. +func InputOverrides(spec *v1beta2.NativeSearchInput) (map[string]any, error) { + if spec.AISimulate == nil || spec.AISimulate.InputOverrides == nil { + return nil, nil + } + raw := spec.AISimulate.InputOverrides.Raw + if len(raw) == 0 && spec.AISimulate.InputOverrides.Object != nil { + var err error + if raw, err = json.Marshal(spec.AISimulate.InputOverrides.Object); err != nil { + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: %w", err) + } + } + if len(raw) == 0 { + return nil, nil + } + var value any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: trailing JSON value") + } + switch typed := value.(type) { + case nil: + return nil, nil + case map[string]any: + return typed, nil + default: + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: must be a JSON object, got %T", value) + } +} + +// Underlay translates the typed spec into the public CoreRecommendationConfig input consumed by +// `aisimulate recommend`. Typed fields that are unset produce no key, so AISimulate defaults and +// input overrides decide them. +func Underlay(spec *v1beta2.NativeSearchInput) (map[string]any, error) { + b := builder{root: map[string]any{}} + + if spec.Model != nil { + b.set(spec.Model.Name, engineModel) + } + if len(spec.Backends) > 0 { + backends := make([]string, len(spec.Backends)) + for i, backend := range spec.Backends { + backends[i] = string(backend) + } + b.setStrings(backends, engineBackendChoices) + } + if spec.Hardware != nil && spec.Hardware.GPU != nil { + if sku := spec.Hardware.GPU.SKU; sku != nil && *sku != "" { + b.set(*sku, engineHardware) + } + if budget := spec.Hardware.GPU.Budget; budget != nil { + b.set(int64(*budget), optimizationHardwareBudget) + } + } + + if spec.Workload != nil && spec.Workload.Static != nil { + static := spec.Workload.Static + if hasStaticWorkloadFields(static) { + b.set("synthetic", trafficSourceType) + } + if static.InputTokens != nil { + b.set(int64(*static.InputTokens), trafficSourceInputTokens) + } + if static.OutputTokens != nil { + b.set(int64(*static.OutputTokens), trafficSourceOutputTokens) + } + if load := static.Load; load != nil { + switch { + case load.RequestsPerSecond != nil: + b.set("constant_rate", trafficLoadType) + b.set(*load.RequestsPerSecond, trafficLoadRequestsPerSecond) + case load.Concurrency != nil: + b.set("concurrency", trafficLoadType) + b.set(int64(*load.Concurrency), trafficLoadConcurrency) + case load.KVCapacityFraction != nil: + b.set("kv_capacity_fraction", trafficLoadType) + fraction := load.KVCapacityFraction + switch { + case fraction.Value != nil: + b.set(*fraction.Value, trafficLoadFraction) + case fraction.Range != nil: + b.set(map[string]any{"range": map[string]any{ + "min": fraction.Range.Min, + "max": fraction.Range.Max, + }}, trafficLoadFraction) + } + } + } + } + + if objective := spec.Objective; objective != nil { + if objective.Mode == v1beta2.ObjectiveModePareto { + if len(objective.Metrics) > 0 && !isDefaultParetoMetrics(objective.Metrics) { + b.err = fmt.Errorf("spec.objective.metrics: the AISimulate public recommendation input only exposes its default Pareto objectives") + } else { + b.set(TargetPareto, optimizationTarget) + } + } else if objective.Metric != nil { + target, err := publicMetric(*objective.Metric) + if err != nil { + b.err = err + } else { + b.set(target, optimizationTarget) + } + } + if sla := objective.SLA; sla != nil { + if sla.TTFTMs != nil { + b.set(*sla.TTFTMs, evaluationSLATTFTMs) + } + if sla.ITLMs != nil { + b.set(*sla.ITLMs, evaluationSLAITLMs) + } + if sla.E2EMs != nil { + b.set(*sla.E2EMs, evaluationSLAE2EMs) + } + } + } + return b.root, b.err +} + +func hasStaticWorkloadFields(static *v1beta2.StaticWorkloadSpec) bool { + return static.InputTokens != nil || static.OutputTokens != nil || static.Load != nil +} + +func isDefaultParetoMetrics(metrics []v1beta2.ObjectiveMetric) bool { + if len(metrics) != 2 { + return false + } + found := map[v1beta2.ObjectiveMetric]bool{} + for _, metric := range metrics { + found[metric] = true + } + return found[v1beta2.ObjectiveMetric("ThroughputPerGPU")] && found[v1beta2.ObjectiveMetric("ThroughputPerUser")] +} + +// builder writes public input values, remembering the first failure so the translation reads +// as a straight list of assignments. Every path is written under a map the caller created +// and every value is a JSON scalar or a list of them, so a failure means a bad path. +type builder struct { + root map[string]any + err error +} + +func (b *builder) set(value any, path []string) { + if b.err != nil { + return + } + if err := unstructured.SetNestedField(b.root, value, path...); err != nil { + b.err = fmt.Errorf("set %s: %w", strings.Join(path, "."), err) + } +} + +func (b *builder) setStrings(values []string, path []string) { + if b.err != nil { + return + } + if err := unstructured.SetNestedStringSlice(b.root, values, path...); err != nil { + b.err = fmt.Errorf("set %s: %w", strings.Join(path, "."), err) + } +} + +// publicMetric maps each public API metric to its explicit AISimulate optimization target. +func publicMetric(metric v1beta2.ObjectiveMetric) (string, error) { + switch metric { + case v1beta2.ObjectiveMetric("Throughput"): + return "throughput", nil + case v1beta2.ObjectiveMetric("ThroughputPerGPU"): + return "throughput_per_gpu", nil + case v1beta2.ObjectiveMetric("ThroughputPerUser"): + return "throughput_per_user", nil + case v1beta2.ObjectiveMetric("TTFT"): + return "ttft", nil + case v1beta2.ObjectiveMetric("E2ELatency"): + return "e2e_latency", nil + case v1beta2.ObjectiveMetric("Goodput"): + return "goodput", nil + case v1beta2.ObjectiveMetric("GoodputPerGPU"): + return "goodput_per_gpu", nil + default: + return "", fmt.Errorf("spec.objective.metric: unsupported value %q", metric) + } +} + +// normalize round-trips operator-owned maps through JSON so their Go-specific numeric types +// become JSON-compatible values before merging them with the user-owned input overrides. +func normalize(value map[string]any) (map[string]any, error) { + if value == nil { + return nil, nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} + +// mergePatch applies patch to target with RFC 7386 semantics. It returns a new value for +// the merged parts and does not retain the patch's maps for null members. +func mergePatch(target, patch any) any { + patchMap, ok := patch.(map[string]any) + if !ok { + return patch + } + targetMap, ok := target.(map[string]any) + if !ok { + targetMap = map[string]any{} + } + for key, value := range patchMap { + if value == nil { + delete(targetMap, key) + continue + } + targetMap[key] = mergePatch(targetMap[key], value) + } + return targetMap +} diff --git a/deploy/operator/internal/aisimulate/input_test.go b/deploy/operator/internal/aisimulate/input_test.go new file mode 100644 index 000000000000..81ffb914bee2 --- /dev/null +++ b/deploy/operator/internal/aisimulate/input_test.go @@ -0,0 +1,489 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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 aisimulate + +import ( + "encoding/json" + "strings" + "testing" + + v1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// defaultedSpec is a model-only Request after structural and admission defaults: +// fixed-concurrency traffic, the scalar objective with latency bounds, and discovered hardware. +func defaultedSpec() *v1beta2.DynamoGraphDeploymentRequestSpec { + metric := v1beta2.ObjectiveMetric("ThroughputPerGPU") + return &v1beta2.DynamoGraphDeploymentRequestSpec{ + Model: &v1beta2.ModelSpec{Name: "meta-llama/Llama-3.1-8B-Instruct"}, + Backends: []v1beta2.Backend{v1beta2.BackendVLLM, v1beta2.BackendSGLang, v1beta2.BackendTRTLLM}, + Hardware: &v1beta2.HardwareSpec{GPU: &v1beta2.GPUHardwareSpec{SKU: new("h200_sxm"), Budget: new(int32(8))}}, + Workload: &v1beta2.WorkloadSpec{Static: &v1beta2.StaticWorkloadSpec{ + InputTokens: new(int32(1024)), + OutputTokens: new(int32(128)), + Load: &v1beta2.StaticLoadSpec{Concurrency: new(int32(10))}, + }}, + Objective: &v1beta2.ObjectiveSpec{ + Mode: v1beta2.ObjectiveModeOptimize, + Metric: &metric, + SLA: &v1beta2.ObjectiveSLASpec{TTFTMs: new(2000.0), ITLMs: new(30.0)}, + }, + Recommendation: &v1beta2.RecommendationSpec{MaxCandidates: 1}, + } +} + +// withSLA restores the model-only latency defaults after a test changes them. +func withSLA(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoGraphDeploymentRequestSpec { + spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{TTFTMs: new(2000.0), ITLMs: new(30.0)} + return spec +} + +// withoutSLA models a caller that explicitly opted out with objective.sla: {}. +func withoutSLA(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoGraphDeploymentRequestSpec { + spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{} + return spec +} + +// withKVRange replaces the fixed concurrency with a KV-load range search. +func withKVRange(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoGraphDeploymentRequestSpec { + spec.Workload.Static = &v1beta2.StaticWorkloadSpec{ + InputTokens: new(int32(4000)), + OutputTokens: new(int32(1000)), + Load: &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{ + Range: &v1beta2.KVCapacityFractionRange{Min: 0.1, Max: 1}, + }}, + } + return spec +} + +func withInputOverrides(spec *v1beta2.DynamoGraphDeploymentRequestSpec, overrides string) *v1beta2.DynamoGraphDeploymentRequestSpec { + spec.AISimulate = &v1beta2.AISimulateSpec{InputOverrides: &runtime.RawExtension{Raw: []byte(overrides)}} + return spec +} + +func lower(t *testing.T, spec *v1beta2.DynamoGraphDeploymentRequestSpec) map[string]any { + t.Helper() + input, err := Lower(&spec.NativeSearchInput) + if err != nil { + t.Fatalf("Lower: %v", err) + } + return input +} + +func encode(t *testing.T, input map[string]any) string { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func underlay(t *testing.T, spec *v1beta2.DynamoGraphDeploymentRequestSpec) map[string]any { + t.Helper() + input, err := Underlay(&spec.NativeSearchInput) + if err != nil { + t.Fatalf("Underlay: %v", err) + } + return input +} + +func nested(t *testing.T, input map[string]any, path ...string) any { + t.Helper() + value, _, err := unstructured.NestedFieldNoCopy(input, path...) + if err != nil { + t.Fatal(err) + } + return value +} + +func TestLowerModelOnlyRequest(t *testing.T) { + want := `{"engine":{"backend":{"choices":["vllm","sglang","trtllm"]},"hardware":"h200_sxm","model":"meta-llama/Llama-3.1-8B-Instruct","workers":{"aggregated":{},"decode":{},"prefill":{}}},` + + `"evaluation":{"sla":{"itl_ms":30,"ttft_ms":2000}},` + + `"optimization":{"constraints":{"max_candidate_gpus":8},"strict_sla":true,"target":"throughput_per_gpu"},` + + `"optimizer":{"max_trials":320},` + + `"traffic":{"load":{"concurrency":10,"type":"concurrency"},"source":{"input_tokens":1024,"output_tokens":128,"type":"synthetic"},"stop":{"requests":1000}}}` + if got := encode(t, lower(t, defaultedSpec())); got != want { + t.Fatalf("effective input\n got: %s\nwant: %s", got, want) + } +} + +func TestLowerIsDeterministicAndDoesNotMutateTheSpec(t *testing.T) { + const overrides = `{"optimizer":{"max_trials":100},"traffic":{"stop":{"requests":5}}}` + spec := withInputOverrides(defaultedSpec(), overrides) + first, second := encode(t, lower(t, spec)), encode(t, lower(t, spec)) + if first != second { + t.Fatalf("not deterministic:\n%s\n%s", first, second) + } + if string(spec.AISimulate.InputOverrides.Raw) != overrides { + t.Fatalf("overrides mutated: %s", spec.AISimulate.InputOverrides.Raw) + } +} + +func TestLowerExplicitRequest(t *testing.T) { + metric := v1beta2.ObjectiveMetric("Throughput") + spec := withSLA(defaultedSpec()) + spec.Backends = []v1beta2.Backend{v1beta2.BackendVLLM, v1beta2.BackendSGLang} + spec.Workload.Static = &v1beta2.StaticWorkloadSpec{ + InputTokens: new(int32(4000)), + OutputTokens: new(int32(1000)), + Load: &v1beta2.StaticLoadSpec{RequestsPerSecond: new(10.0)}, + } + spec.Objective.Metric = &metric + spec = withInputOverrides(spec, `{"traffic":{"stop":{"requests":1000}},"optimizer":{"max_trials":100}}`) + + want := `{"engine":{"backend":{"choices":["vllm","sglang"]},"hardware":"h200_sxm","model":"meta-llama/Llama-3.1-8B-Instruct","workers":{"aggregated":{},"decode":{},"prefill":{}}},` + + `"evaluation":{"sla":{"itl_ms":30,"ttft_ms":2000}},` + + `"optimization":{"constraints":{"max_candidate_gpus":8},"strict_sla":true,"target":"throughput"},` + + `"optimizer":{"max_trials":100},` + + `"traffic":{"load":{"requests_per_second":10,"type":"constant_rate"},"source":{"input_tokens":4000,"output_tokens":1000,"type":"synthetic"},"stop":{"requests":1000}}}` + if got := encode(t, lower(t, spec)); got != want { + t.Fatalf("effective input\n got: %s\nwant: %s", got, want) + } +} + +func TestMergePatchSemantics(t *testing.T) { + cases := []struct{ name, target, patch, want string }{ + {"scalar replaces", `{"a":1}`, `{"a":2}`, `{"a":2}`}, + {"object merges recursively", `{"a":{"b":1,"c":2}}`, `{"a":{"b":3}}`, `{"a":{"b":3,"c":2}}`}, + {"null deletes", `{"a":1,"b":2}`, `{"a":null}`, `{"b":2}`}, + {"array replaces", `{"a":[1,2,3]}`, `{"a":[4]}`, `{"a":[4]}`}, + {"object replaces scalar and drops nulls", `{"a":1}`, `{"a":{"b":null,"c":1}}`, `{"a":{"c":1}}`}, + {"null on absent key is a no-op", `{"a":1}`, `{"z":null}`, `{"a":1}`}, + {"empty patch", `{"a":1}`, `{}`, `{"a":1}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var target, patch any + if err := json.Unmarshal([]byte(tc.target), &target); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(tc.patch), &patch); err != nil { + t.Fatal(err) + } + got, _ := json.Marshal(mergePatch(target, patch)) + if string(got) != tc.want { + t.Fatalf("got %s want %s", got, tc.want) + } + }) + } + t.Run("nil patch map", func(t *testing.T) { + target := map[string]any{"a": 1.0} + if got := mergePatch(target, map[string]any(nil)); got == nil { + t.Fatal("a nil patch must keep the target") + } + }) +} + +func TestInputOverridesWinOverTheTypedUnderlay(t *testing.T) { + t.Run("a null deletes a typed field", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"traffic":{"source":{"input_tokens":null}}}`)) + if nested(t, input, "traffic", "source", "input_tokens") != nil { + t.Fatalf("inputTokens survived an override null: %s", encode(t, input)) + } + }) + t.Run("a value overrides a typed field", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"traffic":{"source":{"input_tokens":8000}},"optimization":{"constraints":{"max_candidate_gpus":16}}}`)) + if nested(t, input, "traffic", "source", "input_tokens") != json.Number("8000") || nested(t, input, "optimization", "constraints", "max_candidate_gpus") != json.Number("16") { + t.Fatalf("input overrides did not win: %s", encode(t, input)) + } + }) + t.Run("a target overrides the typed metric", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"optimization":{"target":"ttft"}}`)) + if nested(t, input, "optimization", "target") != "ttft" { + t.Fatalf("typed metric beat the override target: %s", encode(t, input)) + } + }) + t.Run("an SLA bound overrides only the bound it names", func(t *testing.T) { + input := lower(t, withInputOverrides(withSLA(defaultedSpec()), `{"evaluation":{"sla":{"ttft_ms":500}}}`)) + if nested(t, input, "evaluation", "sla", "ttft_ms") != json.Number("500") || nested(t, input, "evaluation", "sla", "itl_ms") != 30.0 { + t.Fatalf("unexpected SLA: %s", encode(t, input)) + } + }) +} + +func TestRequestCountDefault(t *testing.T) { + t.Run("derived from the typed underlay", func(t *testing.T) { + if nested(t, lower(t, defaultedSpec()), "traffic", "stop", "requests") != 1000.0 { + t.Fatal("traffic.stop.requests not derived") + } + }) + t.Run("an override ratio deletes the derived count itself", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"traffic":{"stop":{"requests_per_load_unit":10,"requests":null}}}`)) + if nested(t, input, "traffic", "stop", "requests") != nil || nested(t, input, "traffic", "stop", "requests_per_load_unit") != json.Number("10") { + t.Fatalf("unexpected workload: %s", encode(t, input)) + } + }) + t.Run("an override null deletes the default", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"traffic":{"stop":{"requests":null}}}`)) + if nested(t, input, "traffic", "stop", "requests") != nil { + t.Fatal("request count re-added after an override null") + } + }) + t.Run("not added without a workload", func(t *testing.T) { + spec := defaultedSpec() + spec.Workload = &v1beta2.WorkloadSpec{} + if nested(t, lower(t, spec), "traffic") != nil { + t.Fatal("a workload appeared from nothing") + } + }) +} + +func TestMaxTrialsDefault(t *testing.T) { + emptyWorkload := defaultedSpec() + emptyWorkload.Workload = &v1beta2.WorkloadSpec{} + + for name, spec := range map[string]*v1beta2.DynamoGraphDeploymentRequestSpec{ + "model-only fixed concurrency": defaultedSpec(), + "KV-load range": withKVRange(defaultedSpec()), + "empty workload": emptyWorkload, + } { + t.Run(name, func(t *testing.T) { + if nested(t, lower(t, spec), "optimizer", "max_trials") != 320.0 { + t.Fatal("optimizer.max_trials not derived") + } + }) + } + t.Run("override value or null wins", func(t *testing.T) { + if nested(t, lower(t, withInputOverrides(defaultedSpec(), `{"optimizer":{"max_trials":7}}`)), "optimizer", "max_trials") != json.Number("7") { + t.Fatal("override max_trials ignored") + } + if nested(t, lower(t, withInputOverrides(defaultedSpec(), `{"optimizer":{"max_trials":null}}`)), "optimizer", "max_trials") != nil { + t.Fatal("max_trials re-added after an override null") + } + }) +} + +func TestStrictSLADerivation(t *testing.T) { + t.Run("enabled when the typed SLA has a bound", func(t *testing.T) { + if nested(t, lower(t, withSLA(defaultedSpec())), "optimization", "strict_sla") != true { + t.Fatal("strict_sla not derived") + } + }) + t.Run("an absent SLA derives nothing", func(t *testing.T) { + spec := defaultedSpec() + spec.Objective.SLA = nil + input := lower(t, spec) + if nested(t, input, "optimization", "strict_sla") != nil || nested(t, input, "evaluation", "sla") != nil { + t.Fatalf("model-only input has SLA keys: %s", encode(t, input)) + } + }) + t.Run("an empty SLA derives nothing", func(t *testing.T) { + input := lower(t, withoutSLA(defaultedSpec())) + if nested(t, input, "optimization", "strict_sla") != nil || nested(t, input, "evaluation", "sla") != nil { + t.Fatalf("an empty SLA produced SLA keys: %s", encode(t, input)) + } + }) + t.Run("an override bound alone does not derive it", func(t *testing.T) { + input := lower(t, withInputOverrides(withoutSLA(defaultedSpec()), `{"evaluation":{"sla":{"ttft_ms":500}}}`)) + if nested(t, input, "evaluation", "sla", "ttft_ms") != json.Number("500") || nested(t, input, "optimization", "strict_sla") != nil { + t.Fatalf("unexpected optimization input: %s", encode(t, input)) + } + }) + t.Run("override value is kept", func(t *testing.T) { + if nested(t, lower(t, withInputOverrides(withSLA(defaultedSpec()), `{"optimization":{"strict_sla":false}}`)), "optimization", "strict_sla") != false { + t.Fatal("override strict_sla was ignored") + } + }) + t.Run("is not revisited after the input override", func(t *testing.T) { + input := lower(t, withInputOverrides(withSLA(defaultedSpec()), `{"evaluation":{"sla":{"ttft_ms":null,"itl_ms":null}}}`)) + if nested(t, input, "optimization", "strict_sla") != true { + t.Fatalf("the derived strict_sla changed after override bounds were deleted: %s", encode(t, input)) + } + }) +} + +func TestInputOverridesApplyOnceAfterDerivedDefaults(t *testing.T) { + t.Run("optimizer null removes the complete derived optimizer", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"optimizer":null}`)) + if nested(t, input, "optimizer") != nil { + t.Fatalf("optimizer survived an override null: %s", encode(t, input)) + } + }) + t.Run("max_trials null removes only max_trials", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"optimizer":{"max_trials":null,"seed":3}}`)) + if nested(t, input, "optimizer", "max_trials") != nil || nested(t, input, "optimizer", "seed") != json.Number("3") { + t.Fatalf("unexpected optimizer: %s", encode(t, input)) + } + }) + t.Run("an override max_trials replaces the derived value", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"optimizer":{"max_trials":7}}`)) + if nested(t, input, "optimizer", "max_trials") != json.Number("7") { + t.Fatalf("unexpected optimizer: %s", encode(t, input)) + } + }) + t.Run("cross-field defaults are not derived from values only overrides introduce", func(t *testing.T) { + input := lower(t, withInputOverrides(withoutSLA(defaultedSpec()), `{"traffic":{"load":{"fraction":{"range":{"min":0.1,"max":1}}}},"evaluation":{"sla":{"ttft_ms":500}}}`)) + if nested(t, input, "optimizer", "max_trials") != 320.0 || nested(t, input, "optimization", "strict_sla") != nil { + t.Fatalf("a field was derived from override-only values: %s", encode(t, input)) + } + }) +} + +func TestUnderlayParetoObjective(t *testing.T) { + spec := defaultedSpec() + spec.Objective.Mode = v1beta2.ObjectiveModePareto + spec.Objective.Metric = nil + spec.Objective.Metrics = []v1beta2.ObjectiveMetric{"ThroughputPerGPU", "ThroughputPerUser"} + input := underlay(t, spec) + if nested(t, input, "optimization", "target") != TargetPareto { + t.Fatalf("not a Pareto optimization: %v", input["optimization"]) + } + + spec.Objective.Metrics = []v1beta2.ObjectiveMetric{"Throughput", "TTFT"} + if _, err := Underlay(&spec.NativeSearchInput); err == nil { + t.Fatal("custom Pareto objectives must fail until the public AISimulate input exposes them") + } +} + +func TestUnderlayOmitsUnsetTypedFields(t *testing.T) { + spec := &v1beta2.DynamoGraphDeploymentRequestSpec{Model: &v1beta2.ModelSpec{Name: "m"}} + if got := encode(t, underlay(t, spec)); got != `{"engine":{"model":"m"}}` { + t.Fatalf("got %s", got) + } + spec.Hardware = &v1beta2.HardwareSpec{GPU: &v1beta2.GPUHardwareSpec{SKU: new("")}} + if got := encode(t, underlay(t, spec)); got != `{"engine":{"model":"m"}}` { + t.Fatalf("an empty SKU must not lower: %s", got) + } +} + +func TestUnderlayKVCapacityFractionShapes(t *testing.T) { + spec := defaultedSpec() + spec.Workload.Static.Load = &v1beta2.StaticLoadSpec{ + KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{Value: new(0.25)}, + } + if nested(t, underlay(t, spec), "traffic", "load", "fraction") != 0.25 { + t.Fatal("a fixed fraction must lower to a scalar") + } + spec.Workload.Static.Load.KVCapacityFraction = &v1beta2.KVCapacityFractionSpec{ + Range: &v1beta2.KVCapacityFractionRange{Min: 0.1, Max: 0.9}, + } + if got := nested(t, underlay(t, spec), "traffic", "load", "fraction", "range", "min"); got != 0.1 { + t.Fatalf("range min = %v, want 0.1", got) + } + if got := nested(t, underlay(t, spec), "traffic", "load", "fraction", "range", "max"); got != 0.9 { + t.Fatalf("range max = %v, want 0.9", got) + } +} + +func TestLowerWithUnderlayKeepsInputOverridesAuthoritative(t *testing.T) { + spec := withInputOverrides(defaultedSpec(), `{"dgdr_run":{"name":"custom","runtime_image":null}}`) + input, err := LowerWithUnderlay(&spec.NativeSearchInput, map[string]any{"dgdr_run": map[string]any{ + "name": "default", "runtime_image": "planner:1.2.3", "max_candidates": int64(1), + }}) + if err != nil { + t.Fatalf("LowerWithUnderlay: %v", err) + } + if nested(t, input, "dgdr_run", "name") != "custom" { + t.Fatalf("input override did not replace the execution name: %s", encode(t, input)) + } + if nested(t, input, "dgdr_run", "runtime_image") != nil { + t.Fatalf("input override did not delete the runtime image: %s", encode(t, input)) + } + if nested(t, input, "dgdr_run", "max_candidates") != float64(1) { + t.Fatalf("static execution underlay was lost: %s", encode(t, input)) + } +} + +func TestLowerIncludesPublicEngineRoleUnderlay(t *testing.T) { + workers, found, err := unstructured.NestedMap(lower(t, defaultedSpec()), engineWorkers...) + if err != nil || !found { + t.Fatalf("engine.workers: found=%v err=%v", found, err) + } + for _, role := range []string{"aggregated", "prefill", "decode"} { + worker, ok := workers[role].(map[string]any) + if !ok || len(worker) != 0 { + t.Errorf("engine.workers.%s = %#v, want an empty object", role, workers[role]) + } + } +} + +func TestInputOverridesMustBeAnObject(t *testing.T) { + for _, overrides := range []string{`[1]`, `"x"`, `3`, `{`} { + spec := withInputOverrides(defaultedSpec(), overrides) + if _, err := Lower(&spec.NativeSearchInput); err == nil { + t.Errorf("Lower accepted overrides %s", overrides) + } + } + for _, overrides := range []string{``, `null`, `{}`} { + spec := withInputOverrides(defaultedSpec(), overrides) + if _, err := Lower(&spec.NativeSearchInput); err != nil { + t.Errorf("Lower rejected overrides %q: %v", overrides, err) + } + } +} + +func TestInputOverridesPreserveLargeIntegers(t *testing.T) { + const seed = "9007199254740993" + + t.Log("Lowering the opaque override without passing its integer through float64") + input := lower(t, withInputOverrides(defaultedSpec(), `{"optimizer":{"seed":`+seed+`}}`)) + if got := nested(t, input, "optimizer", "seed"); got != json.Number(seed) { + t.Fatalf("optimizer.seed = %#v, want exact JSON number %s", got, seed) + } + if got := encode(t, input); !strings.Contains(got, `"seed":`+seed) { + t.Fatalf("encoded input lost integer precision: %s", got) + } +} + +func TestHasPath(t *testing.T) { + overrides := map[string]any{ + "optimization": map[string]any{"target": nil}, + "evaluation": map[string]any{"sla": map[string]any{"ttft_ms": 1.0}}, + "traffic": map[string]any{"source": map[string]any{"input_tokens": 8.0}}, + } + for path, want := range map[string]bool{ + "optimization.target": true, // a null still names the key + "evaluation.sla": true, + "evaluation.sla.ttft_ms": true, + "evaluation.sla.itl_ms": false, + "traffic.source.input_tokens": true, + "traffic.source.output_tokens": false, + "optimizer.max_trials": false, + "optimization.target.deeper": false, + "traffic.source.input_tokens.foo": false, + } { + if got := has(overrides, strings.Split(path, ".")...); got != want { + t.Errorf("has(%s) = %v, want %v", path, got, want) + } + } + if has(nil, "optimization") { + t.Error("nothing is named in nil overrides") + } +} + +func TestPublicMetricNames(t *testing.T) { + for metric, want := range map[v1beta2.ObjectiveMetric]string{ + "Throughput": "throughput", + "ThroughputPerGPU": "throughput_per_gpu", + "ThroughputPerUser": "throughput_per_user", + "TTFT": "ttft", + "E2ELatency": "e2e_latency", + "Goodput": "goodput", + "GoodputPerGPU": "goodput_per_gpu", + } { + if got, err := publicMetric(metric); err != nil || got != want { + t.Errorf("publicMetric(%s) = %q, %v; want %q", metric, got, err, want) + } + } + if got, err := publicMetric("FutureMetric"); err == nil || got != "" { + t.Errorf("publicMetric(FutureMetric) = %q, %v; want an explicit rejection", got, err) + } +} diff --git a/deploy/operator/internal/aisimulate/paths.go b/deploy/operator/internal/aisimulate/paths.go new file mode 100644 index 000000000000..25ef533ecd6c --- /dev/null +++ b/deploy/operator/internal/aisimulate/paths.go @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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 aisimulate + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +// Public recommendation input paths, in the snake_case spelling of the upstream AISimulate +// contract. They address the same key when the underlay is built and when derived defaults are read, +// and are passed to the unstructured helpers, which must not modify them. +var ( + engineModel = []string{"engine", "model"} + engineBackendChoices = []string{"engine", "backend", "choices"} + engineHardware = []string{"engine", "hardware"} + engineWorkers = []string{"engine", "workers"} + + optimizationTarget = []string{"optimization", "target"} + optimizationHardwareBudget = []string{"optimization", "constraints", "max_candidate_gpus"} + optimizationStrictSLA = []string{"optimization", "strict_sla"} + evaluationSLATTFTMs = []string{"evaluation", "sla", "ttft_ms"} + evaluationSLAITLMs = []string{"evaluation", "sla", "itl_ms"} + evaluationSLAE2EMs = []string{"evaluation", "sla", "e2e_ms"} + optimizerMaxTrials = []string{"optimizer", "max_trials"} + + trafficPath = []string{"traffic"} + trafficSourceType = []string{"traffic", "source", "type"} + trafficSourceInputTokens = []string{"traffic", "source", "input_tokens"} + trafficSourceOutputTokens = []string{"traffic", "source", "output_tokens"} + trafficLoadType = []string{"traffic", "load", "type"} + trafficLoadConcurrency = []string{"traffic", "load", "concurrency"} + trafficLoadRequestsPerSecond = []string{"traffic", "load", "requests_per_second"} + trafficLoadFraction = []string{"traffic", "load", "fraction"} + trafficStopRequests = []string{"traffic", "stop", "requests"} +) + +// TargetPareto is optimization.target for a multi-objective search. +const TargetPareto = "pareto" + +// has reports whether obj contains the path, even with a null value. Applied to +// spec.aisimulate.inputOverrides it tells whether the caller named a key, including to delete +// it. +func has(obj map[string]any, path ...string) bool { + _, found, _ := unstructured.NestedFieldNoCopy(obj, path...) + return found +} diff --git a/deploy/operator/internal/controller/aisimulate/defaults.go b/deploy/operator/internal/controller/aisimulate/defaults.go deleted file mode 100644 index 8aecb2d8d569..000000000000 --- a/deploy/operator/internal/controller/aisimulate/defaults.go +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - -// The derived defaults. No API field carries them: each depends on the typed underlay, so -// no single typed field can supply it. -const ( - // DefaultRequestCount is workload.request_count when the typed underlay does not - // choose num_request_ratio. - DefaultRequestCount int64 = 1000 - - // DefaultMaxTrials bounds a KV-load range search that has no explicit sweep.max_trials. - // It matches the trial budget of AISimulate's unified CLI (optimizer.max_trials). - DefaultMaxTrials int64 = 320 -) - -// applyDefaults adds the derived defaults to the typed underlay, before the native -// parameters are applied. spec.aisimulate.parameters is the final overlay: it overrides or -// deletes any derived value, and nothing is derived in response to what it introduces. -// A native change that needs a matching change elsewhere (a request_count next to -// num_request_ratio, a strict_sla next to a new SLA bound) must make it itself. -func applyDefaults(underlay map[string]any) error { - b := builder{root: underlay} - - if has(underlay, workloadPath...) { - if !has(underlay, workloadRequestCount...) && !has(underlay, workloadNumRequestRatio...) { - b.set(float64(DefaultRequestCount), workloadRequestCount) - } - kvLoadRatio, _, _ := unstructured.NestedFieldNoCopy(underlay, workloadKVLoadRatio...) - if _, isRange := kvLoadRatio.([]any); isRange && !has(underlay, sweepMaxTrials...) { - b.set(float64(DefaultMaxTrials), sweepMaxTrials) - } - } - - if hasSLABound(underlay) && !has(underlay, goalStrictSLA...) { - b.set(true, goalStrictSLA) - } - return b.err -} - -// hasSLABound reports whether the input bounds at least one latency. -func hasSLABound(input map[string]any) bool { - for _, path := range [][]string{goalSLATTFTMs, goalSLAITLMs, goalSLAE2EMs} { - if bound, _, _ := unstructured.NestedFieldNoCopy(input, path...); bound != nil { - return true - } - } - return false -} diff --git a/deploy/operator/internal/controller/aisimulate/doc.go b/deploy/operator/internal/controller/aisimulate/doc.go deleted file mode 100644 index a15cb22952e7..000000000000 --- a/deploy/operator/internal/controller/aisimulate/doc.go +++ /dev/null @@ -1,38 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate lowers a v1beta2 DynamoGraphDeploymentRequest spec into the native -// AISimulate input that a Run executes. -// -// Lowering sits below the Request reconciler. Admission does not call it: spec.aisimulate.parameters -// is opaque there, and the validation of the lowered input belongs to the schema of the AISimulate -// release the Request selects. The reconciler lowers the Request once and freezes the output into the -// Run, which executes that object as it is. -// -// Lower works in three steps. The typed spec (model, backends, hardware, workload, objective) -// is first translated into the native input, the "underlay". The derived defaults that no API -// field carries are then added to it: the request count, a bounded trial budget for a -// KV-load range search, and goal.strict_sla when an SLA bound is set. Last, -// spec.aisimulate.parameters is applied on top as a JSON Merge Patch (RFC 7386): native -// objects merge recursively, arrays and scalars replace the value, and null deletes it. The -// parameters are authoritative: they win on every path they name, including the derived -// defaults, and are applied once, last. -// -// The document stays unstructured (map[string]any) because its schema belongs to the -// AISimulate release the Request selects, not to this package. Reads and writes go -// through the helpers of k8s.io/apimachinery/pkg/apis/meta/v1/unstructured. -package aisimulate diff --git a/deploy/operator/internal/controller/aisimulate/image.go b/deploy/operator/internal/controller/aisimulate/image.go deleted file mode 100644 index 60025699e1af..000000000000 --- a/deploy/operator/internal/controller/aisimulate/image.go +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import ( - "errors" - "fmt" - - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" -) - -// aisimulateVersionByDynamoMinor is the release contract between a Dynamo release and the AISimulate -// package its planner image bundles, keyed by the Dynamo major.minor. The image tag is the Dynamo -// version, not the version of the AISimulate package inside the image, and the AISimulate version -// selects the version-specific input schema, so the two are never inferred from one tag. Every -// Dynamo minor bump adds its entry; release_contract_test.go fails until it does. -var aisimulateVersionByDynamoMinor = map[string]string{ - "1.6": "0.13.0", -} - -// ResolveVersion returns the AISimulate version a Request selects: the explicit override when set, -// otherwise the AISimulate version bundled in the Dynamo release named by the image tag. The -// override is the escape hatch for images the contract does not cover (a nightly or custom planner -// image) and names the AISimulate version itself. An image whose tag is not a Dynamo release with a -// mapping entry, or a missing image, is an error; the caller reports it on the field that fixes it. -func ResolveVersion(image, versionOverride string) (string, error) { - if versionOverride != "" { - version, err := versionoverride.Parse(versionOverride) - if err != nil { - return "", fmt.Errorf("AISimulate version override %q: %w", versionOverride, err) - } - return version.String(), nil - } - if image == "" { - return "", errors.New("no AISimulate image") - } - release, err := versionoverride.ParseImageVersion(image) - if err != nil { - return "", fmt.Errorf("resolve AISimulate version: %w", err) - } - minor := fmt.Sprintf("%d.%d", release.Major, release.Minor) - version, ok := aisimulateVersionByDynamoMinor[minor] - if !ok { - return "", fmt.Errorf("image %q is Dynamo %s, which has no bundled AISimulate version in the release contract", image, minor) - } - return version, nil -} diff --git a/deploy/operator/internal/controller/aisimulate/image_test.go b/deploy/operator/internal/controller/aisimulate/image_test.go deleted file mode 100644 index 2a05392112a7..000000000000 --- a/deploy/operator/internal/controller/aisimulate/image_test.go +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import "testing" - -func TestResolveVersion(t *testing.T) { - const mapped = "registry.example.com/aisimulate:1.6.0" - want := aisimulateVersionByDynamoMinor["1.6"] - cases := []struct { - name, image, override, want string - wantErr bool - }{ - {name: "bundled version of the Dynamo release in the tag", image: mapped, want: want}, - {name: "v-prefixed tag", image: "registry.example.com/aisimulate:v1.6.0", want: want}, - {name: "patch release of the same minor", image: "registry.example.com/aisimulate:1.6.3", want: want}, - {name: "prerelease of the same minor", image: "registry.example.com/aisimulate:1.6.0-rc.1", want: want}, - {name: "release without a mapping entry", image: "registry.example.com/aisimulate:1.2.3", wantErr: true}, - {name: "override wins over the mapping", image: mapped, override: "0.99.0", want: "0.99.0"}, - {name: "override rescues a release without an entry", image: "registry.example.com/aisimulate:1.2.3", override: "0.9.0", want: "0.9.0"}, - {name: "override rescues a custom tag", image: "registry.example.com/aisimulate:custom", override: "0.13.1", want: "0.13.1"}, - {name: "override that is not a version", image: mapped, override: "bad", wantErr: true}, - {name: "custom tag needs an override", image: "registry.example.com/aisimulate:custom", wantErr: true}, - {name: "untagged image needs an override", image: "registry.example.com/aisimulate", wantErr: true}, - {name: "registry port is not a tag", image: "registry.example.com:5000/aisimulate", wantErr: true}, - {name: "no image", wantErr: true}, - {name: "override without an image", override: "0.13.0", want: "0.13.0"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := ResolveVersion(tc.image, tc.override) - if (err != nil) != tc.wantErr || got != tc.want { - t.Fatalf("ResolveVersion(%q, %q) = %q, %v; want %q (error %v)", tc.image, tc.override, got, err, tc.want, tc.wantErr) - } - }) - } -} diff --git a/deploy/operator/internal/controller/aisimulate/input.go b/deploy/operator/internal/controller/aisimulate/input.go deleted file mode 100644 index fbfee0514080..000000000000 --- a/deploy/operator/internal/controller/aisimulate/input.go +++ /dev/null @@ -1,250 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import ( - "encoding/json" - "fmt" - "strings" - "unicode" - - v1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -// Lower returns the effective native input: the typed underlay, merged with -// spec.aisimulate.parameters, plus the derived defaults. The spec must already carry its -// structural defaults; lowering repeats none of them. The only error is malformed -// parameters (not a JSON object); semantic problems are the caller's to validate. -func Lower(spec *v1beta2.DynamoGraphDeploymentRequestSpec) (map[string]any, error) { - parameters, err := Parameters(spec) - if err != nil { - return nil, err - } - underlay, err := Underlay(spec) - if err != nil { - return nil, err - } - if underlay, err = normalize(underlay); err != nil { - return nil, err - } - if err := applyDefaults(underlay); err != nil { - return nil, err - } - input, _ := mergePatch(underlay, parameters).(map[string]any) - if input == nil { - input = map[string]any{} - } - return input, nil -} - -// Parameters returns spec.aisimulate.parameters as a JSON object, or nil when absent. -func Parameters(spec *v1beta2.DynamoGraphDeploymentRequestSpec) (map[string]any, error) { - if spec.AISimulate == nil || spec.AISimulate.Parameters == nil { - return nil, nil - } - raw := spec.AISimulate.Parameters.Raw - if len(raw) == 0 && spec.AISimulate.Parameters.Object != nil { - var err error - if raw, err = json.Marshal(spec.AISimulate.Parameters.Object); err != nil { - return nil, fmt.Errorf("spec.aisimulate.parameters: %w", err) - } - } - if len(raw) == 0 { - return nil, nil - } - var value any - if err := json.Unmarshal(raw, &value); err != nil { - return nil, fmt.Errorf("spec.aisimulate.parameters: %w", err) - } - switch typed := value.(type) { - case nil: - return nil, nil - case map[string]any: - return typed, nil - default: - return nil, fmt.Errorf("spec.aisimulate.parameters: must be a JSON object, got %T", value) - } -} - -// Underlay translates the typed spec into native input. Typed fields that are unset -// produce no key, so the native defaults and the parameters decide them. -func Underlay(spec *v1beta2.DynamoGraphDeploymentRequestSpec) (map[string]any, error) { - b := builder{root: map[string]any{}} - - if spec.Model != nil { - b.set(spec.Model.Name, searchSpaceModelName) - } - if len(spec.Backends) > 0 { - backends := make([]string, len(spec.Backends)) - for i, backend := range spec.Backends { - backends[i] = string(backend) - } - b.setStrings(backends, searchSpaceBackend) - } - if spec.Hardware != nil && spec.Hardware.GPU != nil { - if sku := spec.Hardware.GPU.SKU; sku != nil && *sku != "" { - b.set(*sku, searchSpaceHardwareSKU) - } - if budget := spec.Hardware.GPU.Budget; budget != nil { - b.set(int64(*budget), searchSpaceGPUBudget) - } - } - - if spec.Workload != nil && spec.Workload.Static != nil { - static := spec.Workload.Static - if static.ISL != nil { - b.set(int64(*static.ISL), workloadISL) - } - if static.OSL != nil { - b.set(int64(*static.OSL), workloadOSL) - } - if static.NumRequestRatio != nil { - b.set(*static.NumRequestRatio, workloadNumRequestRatio) - } - if static.RequestRate != nil { - b.set(*static.RequestRate, workloadRequestRate) - } - if static.Concurrency != nil { - b.set(int64(*static.Concurrency), workloadConcurrency) - } - switch len(static.KVLoadRatio) { - case 0: - case 1: - b.set(static.KVLoadRatio[0], workloadKVLoadRatio) - default: - ratios := make([]any, len(static.KVLoadRatio)) - for i, ratio := range static.KVLoadRatio { - ratios[i] = ratio - } - b.set(ratios, workloadKVLoadRatio) - } - } - - if objective := spec.Objective; objective != nil { - if objective.Mode == v1beta2.ObjectiveModePareto { - b.set(TargetPareto, goalTarget) - if len(objective.Metrics) > 0 { - b.setStrings(nativeMetrics(objective.Metrics), goalParetoObjectives) - } - } else if objective.Metric != nil { - b.set(nativeMetric(*objective.Metric), goalTarget) - } - if sla := objective.SLA; sla != nil { - if sla.TTFTMs != nil { - b.set(*sla.TTFTMs, goalSLATTFTMs) - } - if sla.ITLMs != nil { - b.set(*sla.ITLMs, goalSLAITLMs) - } - if sla.E2EMs != nil { - b.set(*sla.E2EMs, goalSLAE2EMs) - } - } - } - return b.root, b.err -} - -// builder writes native input values, remembering the first failure so the translation reads -// as a straight list of assignments. Every path is written under a map the caller created -// and every value is a JSON scalar or a list of them, so a failure means a bad path. -type builder struct { - root map[string]any - err error -} - -func (b *builder) set(value any, path []string) { - if b.err != nil { - return - } - if err := unstructured.SetNestedField(b.root, value, path...); err != nil { - b.err = fmt.Errorf("set %s: %w", strings.Join(path, "."), err) - } -} - -func (b *builder) setStrings(values []string, path []string) { - if b.err != nil { - return - } - if err := unstructured.SetNestedStringSlice(b.root, values, path...); err != nil { - b.err = fmt.Errorf("set %s: %w", strings.Join(path, "."), err) - } -} - -// nativeMetric maps an API metric to AISimulate's goal.target by converting its CamelCase -// name to snake_case: ThroughputPerGPU becomes throughput_per_gpu and E2ELatency becomes -// e2e_latency. A run of capitals stays together, a digit does not start a word, and the last -// capital of a run starts the next word when a lowercase letter follows it. -func nativeMetric(metric v1beta2.ObjectiveMetric) string { - name := []rune(string(metric)) - var out []rune - for i, r := range name { - if i > 0 && unicode.IsUpper(r) { - prev := name[i-1] - nextLower := i+1 < len(name) && unicode.IsLower(name[i+1]) - if unicode.IsLower(prev) || (unicode.IsUpper(prev) && nextLower) { - out = append(out, '_') - } - } - out = append(out, unicode.ToLower(r)) - } - return string(out) -} - -func nativeMetrics(metrics []v1beta2.ObjectiveMetric) []string { - out := make([]string, len(metrics)) - for i, metric := range metrics { - out[i] = nativeMetric(metric) - } - return out -} - -// normalize round-trips through JSON so every number is a float64, the type the -// parameters decode to. Merge and validation then see one representation. -func normalize(value map[string]any) (map[string]any, error) { - raw, err := json.Marshal(value) - if err != nil { - return nil, err - } - var out map[string]any - if err := json.Unmarshal(raw, &out); err != nil { - return nil, err - } - return out, nil -} - -// mergePatch applies patch to target with RFC 7386 semantics. It returns a new value for -// the merged parts and does not retain the patch's maps for null members. -func mergePatch(target, patch any) any { - patchMap, ok := patch.(map[string]any) - if !ok { - return patch - } - targetMap, ok := target.(map[string]any) - if !ok { - targetMap = map[string]any{} - } - for key, value := range patchMap { - if value == nil { - delete(targetMap, key) - continue - } - targetMap[key] = mergePatch(targetMap[key], value) - } - return targetMap -} diff --git a/deploy/operator/internal/controller/aisimulate/input_test.go b/deploy/operator/internal/controller/aisimulate/input_test.go deleted file mode 100644 index dfeff5a150a0..000000000000 --- a/deploy/operator/internal/controller/aisimulate/input_test.go +++ /dev/null @@ -1,433 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import ( - "encoding/json" - "strings" - "testing" - - v1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" -) - -func ptrTo[T any](v T) *T { return &v } - -// defaultedSpec is a model-only Request as it looks after the CRD's structural defaults and -// admission: the fixed-concurrency workload, the scalar metric and discovered hardware. It -// has no SLA bounds and no KV-load range; withSLA and withKVRange add them. -func defaultedSpec() *v1beta2.DynamoGraphDeploymentRequestSpec { - metric := v1beta2.ObjectiveMetric("Throughput") - return &v1beta2.DynamoGraphDeploymentRequestSpec{ - Model: &v1beta2.ModelSpec{Name: "meta-llama/Llama-3.1-8B-Instruct"}, - Backends: []v1beta2.Backend{v1beta2.BackendVLLM, v1beta2.BackendSGLang, v1beta2.BackendTRTLLM}, - Hardware: &v1beta2.HardwareSpec{GPU: &v1beta2.GPUHardwareSpec{SKU: ptrTo("h200_sxm"), Budget: ptrTo(int32(8))}}, - Workload: &v1beta2.WorkloadSpec{Static: &v1beta2.StaticWorkloadSpec{ - ISL: ptrTo(int32(1024)), OSL: ptrTo(int32(128)), Concurrency: ptrTo(int32(10)), - }}, - Objective: &v1beta2.ObjectiveSpec{Mode: v1beta2.ObjectiveModeOptimize, Metric: &metric}, - Recommendation: &v1beta2.RecommendationSpec{MaxCandidates: 1}, - } -} - -// withSLA adds the latency bounds the earlier defaults used to supply. -func withSLA(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoGraphDeploymentRequestSpec { - spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{TTFTMs: ptrTo(2000.0), ITLMs: ptrTo(30.0)} - return spec -} - -// withKVRange replaces the fixed concurrency with a KV-load range search. -func withKVRange(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoGraphDeploymentRequestSpec { - spec.Workload.Static = &v1beta2.StaticWorkloadSpec{ - ISL: ptrTo(int32(4000)), OSL: ptrTo(int32(1000)), KVLoadRatio: v1beta2.KVLoadRatio{0, 1}, - } - return spec -} - -func withParameters(spec *v1beta2.DynamoGraphDeploymentRequestSpec, parameters string) *v1beta2.DynamoGraphDeploymentRequestSpec { - spec.AISimulate = &v1beta2.AISimulateSpec{Parameters: &runtime.RawExtension{Raw: []byte(parameters)}} - return spec -} - -func lower(t *testing.T, spec *v1beta2.DynamoGraphDeploymentRequestSpec) map[string]any { - t.Helper() - input, err := Lower(spec) - if err != nil { - t.Fatalf("Lower: %v", err) - } - return input -} - -func encode(t *testing.T, input map[string]any) string { - t.Helper() - raw, err := json.Marshal(input) - if err != nil { - t.Fatal(err) - } - return string(raw) -} - -func underlay(t *testing.T, spec *v1beta2.DynamoGraphDeploymentRequestSpec) map[string]any { - t.Helper() - input, err := Underlay(spec) - if err != nil { - t.Fatalf("Underlay: %v", err) - } - return input -} - -func nested(t *testing.T, input map[string]any, path ...string) any { - t.Helper() - value, _, err := unstructured.NestedFieldNoCopy(input, path...) - if err != nil { - t.Fatal(err) - } - return value -} - -func TestLowerModelOnlyRequest(t *testing.T) { - want := `{"goal":{"target":"throughput"},` + - `"search_space":{"backend":["vllm","sglang","trtllm"],"gpu_budget":8,"hardware_sku":"h200_sxm","model_name":"meta-llama/Llama-3.1-8B-Instruct"},` + - `"workload":{"concurrency":10,"isl":1024,"osl":128,"request_count":1000}}` - if got := encode(t, lower(t, defaultedSpec())); got != want { - t.Fatalf("effective input\n got: %s\nwant: %s", got, want) - } -} - -func TestLowerIsDeterministicAndDoesNotMutateTheSpec(t *testing.T) { - const parameters = `{"sweep":{"max_trials":100},"workload":{"request_count":5}}` - spec := withParameters(defaultedSpec(), parameters) - first, second := encode(t, lower(t, spec)), encode(t, lower(t, spec)) - if first != second { - t.Fatalf("not deterministic:\n%s\n%s", first, second) - } - if string(spec.AISimulate.Parameters.Raw) != parameters { - t.Fatalf("parameters mutated: %s", spec.AISimulate.Parameters.Raw) - } -} - -func TestLowerExplicitRequest(t *testing.T) { - metric := v1beta2.ObjectiveMetric("Throughput") - spec := withSLA(defaultedSpec()) - spec.Backends = []v1beta2.Backend{v1beta2.BackendVLLM, v1beta2.BackendSGLang} - spec.Workload.Static = &v1beta2.StaticWorkloadSpec{ISL: ptrTo(int32(4000)), OSL: ptrTo(int32(1000)), RequestRate: ptrTo(10.0)} - spec.Objective.Metric = &metric - spec = withParameters(spec, `{"workload":{"request_count":1000},"sweep":{"max_trials":100}}`) - - want := `{"goal":{"sla":{"itl_ms":30,"ttft_ms":2000},"strict_sla":true,"target":"throughput"},` + - `"search_space":{"backend":["vllm","sglang"],"gpu_budget":8,"hardware_sku":"h200_sxm","model_name":"meta-llama/Llama-3.1-8B-Instruct"},` + - `"sweep":{"max_trials":100},` + - `"workload":{"isl":4000,"osl":1000,"request_count":1000,"request_rate":10}}` - if got := encode(t, lower(t, spec)); got != want { - t.Fatalf("effective input\n got: %s\nwant: %s", got, want) - } -} - -func TestMergePatchSemantics(t *testing.T) { - cases := []struct{ name, target, patch, want string }{ - {"scalar replaces", `{"a":1}`, `{"a":2}`, `{"a":2}`}, - {"object merges recursively", `{"a":{"b":1,"c":2}}`, `{"a":{"b":3}}`, `{"a":{"b":3,"c":2}}`}, - {"null deletes", `{"a":1,"b":2}`, `{"a":null}`, `{"b":2}`}, - {"array replaces", `{"a":[1,2,3]}`, `{"a":[4]}`, `{"a":[4]}`}, - {"object replaces scalar and drops nulls", `{"a":1}`, `{"a":{"b":null,"c":1}}`, `{"a":{"c":1}}`}, - {"null on absent key is a no-op", `{"a":1}`, `{"z":null}`, `{"a":1}`}, - {"empty patch", `{"a":1}`, `{}`, `{"a":1}`}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var target, patch any - if err := json.Unmarshal([]byte(tc.target), &target); err != nil { - t.Fatal(err) - } - if err := json.Unmarshal([]byte(tc.patch), &patch); err != nil { - t.Fatal(err) - } - got, _ := json.Marshal(mergePatch(target, patch)) - if string(got) != tc.want { - t.Fatalf("got %s want %s", got, tc.want) - } - }) - } - t.Run("nil patch map", func(t *testing.T) { - target := map[string]any{"a": 1.0} - if got := mergePatch(target, map[string]any(nil)); got == nil { - t.Fatal("a nil patch must keep the target") - } - }) -} - -func TestNativeValuesWinOverTheTypedUnderlay(t *testing.T) { - t.Run("a native null deletes a typed field", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"workload":{"isl":null}}`)) - if nested(t, input, "workload", "isl") != nil { - t.Fatalf("isl survived a native null: %s", encode(t, input)) - } - }) - t.Run("a native value overrides a typed field", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"workload":{"isl":8000},"search_space":{"gpu_budget":16}}`)) - if nested(t, input, "workload", "isl") != 8000.0 || nested(t, input, "search_space", "gpu_budget") != 16.0 { - t.Fatalf("native values did not win: %s", encode(t, input)) - } - }) - t.Run("a native target overrides the typed metric", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"goal":{"target":"ttft"}}`)) - if nested(t, input, "goal", "target") != "ttft" { - t.Fatalf("typed metric beat the native target: %s", encode(t, input)) - } - }) - t.Run("a native SLA bound overrides only the bound it names", func(t *testing.T) { - input := lower(t, withParameters(withSLA(defaultedSpec()), `{"goal":{"sla":{"ttft_ms":500}}}`)) - if nested(t, input, "goal", "sla", "ttft_ms") != 500.0 || nested(t, input, "goal", "sla", "itl_ms") != 30.0 { - t.Fatalf("unexpected SLA: %s", encode(t, input)) - } - }) -} - -func TestRequestCountDefault(t *testing.T) { - t.Run("derived from the typed underlay", func(t *testing.T) { - if nested(t, lower(t, defaultedSpec()), "workload", "request_count") != 1000.0 { - t.Fatal("request_count not derived") - } - }) - t.Run("a native ratio deletes the derived count itself", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"workload":{"num_request_ratio":10,"request_count":null}}`)) - if nested(t, input, "workload", "request_count") != nil || nested(t, input, "workload", "num_request_ratio") != 10.0 { - t.Fatalf("unexpected workload: %s", encode(t, input)) - } - }) - t.Run("skipped when typed numRequestRatio is set", func(t *testing.T) { - spec := defaultedSpec() - spec.Workload.Static.NumRequestRatio = ptrTo(5.0) - if nested(t, lower(t, spec), "workload", "request_count") != nil { - t.Fatal("request_count defaulted next to typed numRequestRatio") - } - }) - t.Run("native null deletes the default", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"workload":{"request_count":null}}`)) - if nested(t, input, "workload", "request_count") != nil { - t.Fatal("request_count re-added after a native null") - } - }) - t.Run("native deleting the typed ratio does not derive a count", func(t *testing.T) { - spec := defaultedSpec() - spec.Workload.Static.NumRequestRatio = ptrTo(5.0) - input := lower(t, withParameters(spec, `{"workload":{"num_request_ratio":null}}`)) - if nested(t, input, "workload", "request_count") != nil { - t.Fatalf("a count was derived after the native overlay: %s", encode(t, input)) - } - }) - t.Run("not added without a workload", func(t *testing.T) { - spec := defaultedSpec() - spec.Workload = &v1beta2.WorkloadSpec{} - if nested(t, lower(t, spec), "workload") != nil { - t.Fatal("a workload appeared from nothing") - } - }) -} - -func TestMaxTrialsDefault(t *testing.T) { - t.Run("added for a KV-load range", func(t *testing.T) { - if nested(t, lower(t, withKVRange(defaultedSpec())), "sweep", "max_trials") != 320.0 { - t.Fatal("no default max_trials for a range search") - } - }) - t.Run("not derived for the model-only fixed concurrency", func(t *testing.T) { - if input := lower(t, defaultedSpec()); nested(t, input, "sweep") != nil { - t.Fatalf("unexpected sweep: %s", encode(t, input)) - } - }) - t.Run("not added for a pinned load", func(t *testing.T) { - spec := withKVRange(defaultedSpec()) - spec.Workload.Static.KVLoadRatio = v1beta2.KVLoadRatio{0.5} - input := lower(t, spec) - if nested(t, input, "sweep") != nil { - t.Fatalf("unexpected sweep: %s", encode(t, input)) - } - if nested(t, input, "workload", "kv_load_ratio") != 0.5 { - t.Fatal("a one-value ratio should be a scalar") - } - }) - t.Run("native value or null wins", func(t *testing.T) { - if nested(t, lower(t, withParameters(withKVRange(defaultedSpec()), `{"sweep":{"max_trials":7}}`)), "sweep", "max_trials") != 7.0 { - t.Fatal("native max_trials ignored") - } - if nested(t, lower(t, withParameters(withKVRange(defaultedSpec()), `{"sweep":{"max_trials":null}}`)), "sweep", "max_trials") != nil { - t.Fatal("max_trials re-added after a native null") - } - }) -} - -func TestStrictSLADerivation(t *testing.T) { - t.Run("enabled when the typed SLA has a bound", func(t *testing.T) { - if nested(t, lower(t, withSLA(defaultedSpec())), "goal", "strict_sla") != true { - t.Fatal("strict_sla not derived") - } - }) - t.Run("model-only derives nothing", func(t *testing.T) { - input := lower(t, defaultedSpec()) - if nested(t, input, "goal", "strict_sla") != nil || nested(t, input, "goal", "sla") != nil { - t.Fatalf("model-only input has SLA keys: %s", encode(t, input)) - } - }) - t.Run("an empty SLA derives nothing", func(t *testing.T) { - spec := defaultedSpec() - spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{} - input := lower(t, spec) - if nested(t, input, "goal", "strict_sla") != nil || nested(t, input, "goal", "sla") != nil { - t.Fatalf("an empty SLA produced SLA keys: %s", encode(t, input)) - } - }) - t.Run("a native bound alone does not derive it", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"goal":{"sla":{"ttft_ms":500}}}`)) - if nested(t, input, "goal", "sla", "ttft_ms") != 500.0 || nested(t, input, "goal", "strict_sla") != nil { - t.Fatalf("unexpected goal: %s", encode(t, input)) - } - }) - t.Run("native value is kept", func(t *testing.T) { - if nested(t, lower(t, withParameters(withSLA(defaultedSpec()), `{"goal":{"strict_sla":false}}`)), "goal", "strict_sla") != false { - t.Fatal("native strict_sla overridden") - } - }) - t.Run("is not revisited after the native overlay", func(t *testing.T) { - input := lower(t, withParameters(withSLA(defaultedSpec()), `{"goal":{"sla":{"ttft_ms":null,"itl_ms":null}}}`)) - if nested(t, input, "goal", "strict_sla") != true { - t.Fatalf("the derived strict_sla changed after native bounds were deleted: %s", encode(t, input)) - } - }) -} - -func TestParametersApplyOnceAfterDerivedDefaults(t *testing.T) { - t.Run("sweep null removes the complete derived sweep", func(t *testing.T) { - input := lower(t, withParameters(withKVRange(defaultedSpec()), `{"sweep":null}`)) - if nested(t, input, "sweep") != nil { - t.Fatalf("sweep survived a native null: %s", encode(t, input)) - } - }) - t.Run("max_trials null removes only max_trials", func(t *testing.T) { - input := lower(t, withParameters(withKVRange(defaultedSpec()), `{"sweep":{"max_trials":null,"seed":3}}`)) - if nested(t, input, "sweep", "max_trials") != nil || nested(t, input, "sweep", "seed") != 3.0 { - t.Fatalf("unexpected sweep: %s", encode(t, input)) - } - }) - t.Run("a native max_trials replaces the derived value", func(t *testing.T) { - input := lower(t, withParameters(withKVRange(defaultedSpec()), `{"sweep":{"max_trials":7}}`)) - if nested(t, input, "sweep", "max_trials") != 7.0 { - t.Fatalf("unexpected sweep: %s", encode(t, input)) - } - }) - t.Run("nothing is derived from values only parameters introduce", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"workload":{"kv_load_ratio":[0,1]},"goal":{"sla":{"ttft_ms":500}}}`)) - if nested(t, input, "sweep") != nil || nested(t, input, "goal", "strict_sla") != nil { - t.Fatalf("a field was derived from native-only values: %s", encode(t, input)) - } - }) -} - -func TestUnderlayParetoObjective(t *testing.T) { - spec := defaultedSpec() - spec.Objective.Mode = v1beta2.ObjectiveModePareto - spec.Objective.Metric = nil - spec.Objective.Metrics = []v1beta2.ObjectiveMetric{"ThroughputPerGPU", "ThroughputPerUser", "Unknown"} - input := underlay(t, spec) - if nested(t, input, "goal", "target") != TargetPareto { - t.Fatalf("not a pareto goal: %v", input["goal"]) - } - objectives, _, _ := unstructured.NestedSlice(input, "goal", "pareto_objectives") - got, _ := json.Marshal(objectives) - if string(got) != `["throughput_per_gpu","throughput_per_user","unknown"]` { - t.Fatalf("pareto_objectives = %s", got) - } -} - -func TestUnderlayOmitsUnsetTypedFields(t *testing.T) { - spec := &v1beta2.DynamoGraphDeploymentRequestSpec{Model: &v1beta2.ModelSpec{Name: "m"}} - if got := encode(t, underlay(t, spec)); got != `{"search_space":{"model_name":"m"}}` { - t.Fatalf("got %s", got) - } - spec.Hardware = &v1beta2.HardwareSpec{GPU: &v1beta2.GPUHardwareSpec{SKU: ptrTo("")}} - if got := encode(t, underlay(t, spec)); got != `{"search_space":{"model_name":"m"}}` { - t.Fatalf("an empty SKU must not lower: %s", got) - } -} - -func TestUnderlayKVLoadRatioShapes(t *testing.T) { - spec := defaultedSpec() - spec.Workload.Static.KVLoadRatio = v1beta2.KVLoadRatio{0.25} - if nested(t, underlay(t, spec), "workload", "kv_load_ratio") != 0.25 { - t.Fatal("a single ratio lowers to a scalar") - } - spec.Workload.Static.KVLoadRatio = v1beta2.KVLoadRatio{0.1, 0.2, 0.3} - if ratios, _ := nested(t, underlay(t, spec), "workload", "kv_load_ratio").([]any); len(ratios) != 3 { - t.Fatal("longer lists lower unchanged so validation can report them") - } -} - -func TestParametersMustBeAnObject(t *testing.T) { - for _, parameters := range []string{`[1]`, `"x"`, `3`, `{`} { - if _, err := Lower(withParameters(defaultedSpec(), parameters)); err == nil { - t.Errorf("Lower accepted parameters %s", parameters) - } - } - for _, parameters := range []string{``, `null`, `{}`} { - if _, err := Lower(withParameters(defaultedSpec(), parameters)); err != nil { - t.Errorf("Lower rejected parameters %q: %v", parameters, err) - } - } -} - -func TestHasPath(t *testing.T) { - parameters := map[string]any{ - "goal": map[string]any{"target": nil, "sla": map[string]any{"ttft_ms": 1.0}}, - "workload": map[string]any{"isl": 8.0}, - } - for path, want := range map[string]bool{ - "goal.target": true, // a null still names the key - "goal.sla": true, - "goal.sla.ttft_ms": true, - "goal.sla.itl_ms": false, - "workload.isl": true, - "workload.osl": false, - "sweep.max_trials": false, - "goal.target.deeper": false, - "workload.isl.deeper": false, - } { - if got := has(parameters, strings.Split(path, ".")...); got != want { - t.Errorf("has(%s) = %v, want %v", path, got, want) - } - } - if has(nil, "goal") { - t.Error("nothing is named in nil parameters") - } -} - -func TestNativeMetricNames(t *testing.T) { - for metric, want := range map[v1beta2.ObjectiveMetric]string{ - "Throughput": "throughput", - "ThroughputPerGPU": "throughput_per_gpu", - "ThroughputPerUser": "throughput_per_user", - "TTFT": "ttft", - "E2ELatency": "e2e_latency", - "Goodput": "goodput", - "GoodputPerGPU": "goodput_per_gpu", - } { - if got := nativeMetric(metric); got != want { - t.Errorf("nativeMetric(%s) = %q, want %q", metric, got, want) - } - } -} diff --git a/deploy/operator/internal/controller/aisimulate/paths.go b/deploy/operator/internal/controller/aisimulate/paths.go deleted file mode 100644 index bd2a9d16b7d8..000000000000 --- a/deploy/operator/internal/controller/aisimulate/paths.go +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - -// Native input paths, in the snake_case spelling of the upstream AISimulate contract. They -// address the same native key when the underlay is built and when derived defaults are read, -// and are passed to the unstructured helpers, which must not modify them. -var ( - searchSpaceModelName = []string{"search_space", "model_name"} - searchSpaceBackend = []string{"search_space", "backend"} - searchSpaceHardwareSKU = []string{"search_space", "hardware_sku"} - searchSpaceGPUBudget = []string{"search_space", "gpu_budget"} - - workloadPath = []string{"workload"} - workloadISL = []string{"workload", "isl"} - workloadOSL = []string{"workload", "osl"} - workloadNumRequestRatio = []string{"workload", "num_request_ratio"} - workloadRequestRate = []string{"workload", "request_rate"} - workloadConcurrency = []string{"workload", "concurrency"} - workloadKVLoadRatio = []string{"workload", "kv_load_ratio"} - workloadRequestCount = []string{"workload", "request_count"} - - goalTarget = []string{"goal", "target"} - goalParetoObjectives = []string{"goal", "pareto_objectives"} - goalSLATTFTMs = []string{"goal", "sla", "ttft_ms"} - goalSLAITLMs = []string{"goal", "sla", "itl_ms"} - goalSLAE2EMs = []string{"goal", "sla", "e2e_ms"} - goalStrictSLA = []string{"goal", "strict_sla"} - - sweepMaxTrials = []string{"sweep", "max_trials"} -) - -// TargetPareto is the goal.target of a multi-objective search. -const TargetPareto = "pareto" - -// has reports whether obj contains the path, even with a null value. Applied to -// spec.aisimulate.parameters it tells whether the caller named a key, including to delete -// it. -func has(obj map[string]any, path ...string) bool { - _, found, _ := unstructured.NestedFieldNoCopy(obj, path...) - return found -} diff --git a/deploy/operator/internal/controller/aisimulate/release_contract_test.go b/deploy/operator/internal/controller/aisimulate/release_contract_test.go deleted file mode 100644 index a5cf0464b8f8..000000000000 --- a/deploy/operator/internal/controller/aisimulate/release_contract_test.go +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 aisimulate - -import ( - "os" - "path/filepath" - "regexp" - "testing" -) - -// The repository root, relative to this package: deploy/operator/internal/controller/aisimulate. -const repositoryRoot = "../../../../.." - -var ( - pinPattern = regexp.MustCompile(`aisimulate==(\d+\.\d+\.\d+)`) - releasePattern = regexp.MustCompile(`(?m)^version\s*=\s*"(\d+\.\d+)\.`) -) - -func readRepositoryFile(t *testing.T, path string) string { - t.Helper() - raw, err := os.ReadFile(filepath.Join(repositoryRoot, path)) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - return string(raw) -} - -// pinnedAISimulate returns the released core (major.minor.patch) of the aisimulate pin in a file, -// so 0.13.0.dev202610160000000073 normalizes to 0.13.0. -func pinnedAISimulate(t *testing.T, path string) string { - t.Helper() - match := pinPattern.FindStringSubmatch(readRepositoryFile(t, path)) - if match == nil { - t.Fatalf("%s pins no aisimulate==", path) - } - return match[1] -} - -// TestBundledAISimulateVersionMatchesTheDynamoRelease ties the release contract to the repository: -// the AISimulate version the current Dynamo minor maps to must be the one the repository pins. It -// fails when a Dynamo minor bump has no mapping entry, or when the pin moves without the entry. -func TestBundledAISimulateVersionMatchesTheDynamoRelease(t *testing.T) { - canonical := pinnedAISimulate(t, "container/deps/requirements.aisimulate.txt") - for _, path := range []string{"pyproject.toml", "benchmarks/pyproject.toml"} { - if got := pinnedAISimulate(t, path); got != canonical { - t.Errorf("%s pins aisimulate %s, but container/deps/requirements.aisimulate.txt pins %s", path, got, canonical) - } - } - - release := releasePattern.FindStringSubmatch(readRepositoryFile(t, "pyproject.toml")) - if release == nil { - t.Fatal("pyproject.toml has no project version") - } - minor := release[1] - mapped, ok := aisimulateVersionByDynamoMinor[minor] - if !ok { - t.Fatalf("Dynamo %s has no entry in aisimulateVersionByDynamoMinor; add %q: %q with the release", minor, minor, canonical) - } - if mapped != canonical { - t.Fatalf("aisimulateVersionByDynamoMinor[%q] = %q, but the repository pins aisimulate %s", minor, mapped, canonical) - } -} diff --git a/deploy/operator/internal/controller/suite_envtest_test.go b/deploy/operator/internal/controller/suite_envtest_test.go index 0a6c4633cc74..3d2f9edd032c 100644 --- a/deploy/operator/internal/controller/suite_envtest_test.go +++ b/deploy/operator/internal/controller/suite_envtest_test.go @@ -28,10 +28,6 @@ import ( const admissionBypassUsername = "operatorenv-controller-admission-bypass" -// The controller specs create native v1beta2 Requests without an image or hardware. Admission -// completes them from the operator's configuration, as it does in production. -const controllerAISimulateImage = "registry.example/aisimulate:1.2.0" - type fixedHardwareDiscoverer struct{} func (fixedHardwareDiscoverer) Discover(context.Context, nvidiacomv1beta1.GPUSKUType) (*gpu.GPUInfo, error) { @@ -59,8 +55,7 @@ func setupProductionWebhooks(mgr ctrl.Manager, opts operatorenv.WebhookSetupOpti OperatorVersion: opts.OperatorVersion, OperatorPrincipal: opts.OperatorPrincipal, DGDRV1beta2: webhookdefaulting.DGDRV1beta2Options{ - AISimulateImage: controllerAISimulateImage, - Hardware: fixedHardwareDiscoverer{}, + Hardware: fixedHardwareDiscoverer{}, }, }) } diff --git a/deploy/operator/internal/dynamo/component_common.go b/deploy/operator/internal/dynamo/component_common.go index ca4e1f2e9506..404f07a544a9 100644 --- a/deploy/operator/internal/dynamo/component_common.go +++ b/deploy/operator/internal/dynamo/component_common.go @@ -10,7 +10,7 @@ import ( "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta1" commonconsts "github.com/ai-dynamo/dynamo/deploy/operator/internal/consts" controller_common "github.com/ai-dynamo/dynamo/deploy/operator/internal/controller_common" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" corev1 "k8s.io/api/core/v1" "k8s.io/utils/ptr" ) @@ -70,7 +70,7 @@ type ComponentContext struct { Infrastructure configv1alpha1.InfrastructureConfiguration EPPConfig *v1beta1.EPPConfig WorkerHashSuffix string - RuntimeVersion *versionoverride.Version + RuntimeVersion *runtimeversion.Version } func (b *BaseComponentDefaults) GetBaseContainer(context ComponentContext) (corev1.Container, error) { diff --git a/deploy/operator/internal/dynamo/component_frontend_test.go b/deploy/operator/internal/dynamo/component_frontend_test.go index 75129dd10f5d..ac178e3b8be0 100644 --- a/deploy/operator/internal/dynamo/component_frontend_test.go +++ b/deploy/operator/internal/dynamo/component_frontend_test.go @@ -11,7 +11,7 @@ import ( configv1alpha1 "github.com/ai-dynamo/dynamo/deploy/operator/api/config/v1alpha1" "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta1" commonconsts "github.com/ai-dynamo/dynamo/deploy/operator/internal/consts" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" @@ -21,13 +21,13 @@ import ( func TestFrontendDefaultsStartupProbeVersionGate(t *testing.T) { tests := []struct { name string - runtimeVersion *versionoverride.Version + runtimeVersion *runtimeversion.Version wantEnabled bool }{ {name: "unknown legacy runtime"}, - {name: "older runtime", runtimeVersion: &versionoverride.Version{Major: 1, Minor: 5, Patch: 9}}, - {name: "minimum supported runtime", runtimeVersion: &versionoverride.Version{Major: 1, Minor: 6}, wantEnabled: true}, - {name: "newer runtime", runtimeVersion: &versionoverride.Version{Major: 2}, wantEnabled: true}, + {name: "older runtime", runtimeVersion: &runtimeversion.Version{Major: 1, Minor: 5, Patch: 9}}, + {name: "minimum supported runtime", runtimeVersion: &runtimeversion.Version{Major: 1, Minor: 6}, wantEnabled: true}, + {name: "newer runtime", runtimeVersion: &runtimeversion.Version{Major: 2}, wantEnabled: true}, } for _, tt := range tests { diff --git a/deploy/operator/internal/dynamo/component_namespace_test.go b/deploy/operator/internal/dynamo/component_namespace_test.go index 44aebfefcb3f..5919964c0a86 100644 --- a/deploy/operator/internal/dynamo/component_namespace_test.go +++ b/deploy/operator/internal/dynamo/component_namespace_test.go @@ -11,7 +11,7 @@ import ( configv1alpha1 "github.com/ai-dynamo/dynamo/deploy/operator/api/config/v1alpha1" v1beta1 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta1" commonconsts "github.com/ai-dynamo/dynamo/deploy/operator/internal/consts" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -23,13 +23,13 @@ func TestComponentNamespacePrefixRuntimeCompatibility(t *testing.T) { t.Log("render frontend and EPP defaults across the strict-discovery release boundary") versions := []struct { name string - version *versionoverride.Version + version *runtimeversion.Version strict bool }{ {name: "unknown runtime"}, - {name: "older runtime", version: &versionoverride.Version{Major: 1, Minor: 5, Patch: 9}}, - {name: "supported runtime", version: &versionoverride.Version{Major: 1, Minor: 6, Patch: 0}, strict: true}, - {name: "newer runtime", version: &versionoverride.Version{Major: 1, Minor: 7, Patch: 0}, strict: true}, + {name: "older runtime", version: &runtimeversion.Version{Major: 1, Minor: 5, Patch: 9}}, + {name: "supported runtime", version: &runtimeversion.Version{Major: 1, Minor: 6, Patch: 0}, strict: true}, + {name: "newer runtime", version: &runtimeversion.Version{Major: 1, Minor: 7, Patch: 0}, strict: true}, } for _, componentType := range []string{commonconsts.ComponentTypeFrontend, commonconsts.ComponentTypeEPP} { @@ -60,7 +60,7 @@ func TestLegacyEPPDoesNotEnableStrictNamespacePrefix(t *testing.T) { container, err := NewEPPDefaults().GetBaseContainer(ComponentContext{ DynamoNamespace: "default-foo", ComponentType: commonconsts.ComponentTypeEPP, - RuntimeVersion: &versionoverride.Version{Major: 1, Minor: 6, Patch: 0}, + RuntimeVersion: &runtimeversion.Version{Major: 1, Minor: 6, Patch: 0}, EPPConfig: &v1beta1.EPPConfig{}, }) require.NoError(t, err) diff --git a/deploy/operator/internal/dynamo/component_worker_test.go b/deploy/operator/internal/dynamo/component_worker_test.go index 2c399d60724e..aa39447c5b40 100644 --- a/deploy/operator/internal/dynamo/component_worker_test.go +++ b/deploy/operator/internal/dynamo/component_worker_test.go @@ -9,14 +9,14 @@ import ( "testing" commonconsts "github.com/ai-dynamo/dynamo/deploy/operator/internal/consts" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" "github.com/stretchr/testify/require" ) func TestWorkerDefaultsCanaryHealthCheckVersionGate(t *testing.T) { tests := []struct { name string - runtimeVersion *versionoverride.Version + runtimeVersion *runtimeversion.Version wantEnabled string }{ { @@ -25,17 +25,17 @@ func TestWorkerDefaultsCanaryHealthCheckVersionGate(t *testing.T) { }, { name: "older runtime", - runtimeVersion: &versionoverride.Version{Major: 1, Minor: 4, Patch: 9}, + runtimeVersion: &runtimeversion.Version{Major: 1, Minor: 4, Patch: 9}, wantEnabled: "false", }, { name: "minimum supported runtime", - runtimeVersion: &versionoverride.Version{Major: 1, Minor: 5, Patch: 0}, + runtimeVersion: &runtimeversion.Version{Major: 1, Minor: 5, Patch: 0}, wantEnabled: "true", }, { name: "newer runtime", - runtimeVersion: &versionoverride.Version{Major: 2, Minor: 0, Patch: 0}, + runtimeVersion: &runtimeversion.Version{Major: 2, Minor: 0, Patch: 0}, wantEnabled: "true", }, } @@ -64,7 +64,7 @@ func TestWorkerDefaultsCanaryHealthCheckVersionGate(t *testing.T) { func TestWorkerDefaultsLivenessFailureThresholdVersionGate(t *testing.T) { tests := []struct { name string - runtimeVersion *versionoverride.Version + runtimeVersion *runtimeversion.Version wantFailureCount int32 }{ { @@ -73,17 +73,17 @@ func TestWorkerDefaultsLivenessFailureThresholdVersionGate(t *testing.T) { }, { name: "older runtime", - runtimeVersion: &versionoverride.Version{Major: 1, Minor: 4, Patch: 9}, + runtimeVersion: &runtimeversion.Version{Major: 1, Minor: 4, Patch: 9}, wantFailureCount: 1, }, { name: "minimum supported runtime", - runtimeVersion: &versionoverride.Version{Major: 1, Minor: 5, Patch: 0}, + runtimeVersion: &runtimeversion.Version{Major: 1, Minor: 5, Patch: 0}, wantFailureCount: 3, }, { name: "newer runtime", - runtimeVersion: &versionoverride.Version{Major: 2, Minor: 0, Patch: 0}, + runtimeVersion: &runtimeversion.Version{Major: 2, Minor: 0, Patch: 0}, wantFailureCount: 3, }, } diff --git a/deploy/operator/internal/dynamo/graph.go b/deploy/operator/internal/dynamo/graph.go index e13bcd3a006a..ef40a40e98ac 100644 --- a/deploy/operator/internal/dynamo/graph.go +++ b/deploy/operator/internal/dynamo/graph.go @@ -44,7 +44,7 @@ import ( "github.com/ai-dynamo/dynamo/deploy/operator/internal/features" "github.com/ai-dynamo/dynamo/deploy/operator/internal/features/compatibility" gms "github.com/ai-dynamo/dynamo/deploy/operator/internal/gms" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" grovev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1" "github.com/imdario/mergo" "google.golang.org/protobuf/types/known/wrapperspb" @@ -2176,8 +2176,8 @@ func mergeFrontendSidecarDefaults(podSpec *corev1.PodSpec, sidecarName string, p } // Resolve the frontend image independently of the component runtime. - var resolvedRuntimeVersion *versionoverride.Version - if version, err := versionoverride.ParseImageVersion(podSpec.Containers[i].Image); err == nil { + var resolvedRuntimeVersion *runtimeversion.Version + if version, err := runtimeversion.ParseImageVersion(podSpec.Containers[i].Image); err == nil { resolvedRuntimeVersion = &version } @@ -2270,8 +2270,8 @@ func generateComponentContext(component *v1beta1.DynamoComponentDeploymentShared if runtime := GetDynamoContainer(component); runtime != nil { image = runtime.Image } - var resolvedRuntimeVersion *versionoverride.Version - if version, err := versionoverride.Resolve(image, component.RuntimeVersionOverride); err == nil { + var resolvedRuntimeVersion *runtimeversion.Version + if version, err := runtimeversion.Resolve(image, component.RuntimeVersionOverride); err == nil { resolvedRuntimeVersion = &version } else if component.RuntimeVersionOverride != "" { return ComponentContext{}, fmt.Errorf("resolve runtime version override: %w", err) diff --git a/deploy/operator/internal/dynamo/hash.go b/deploy/operator/internal/dynamo/hash.go index de2be302a719..4dd87b283c00 100644 --- a/deploy/operator/internal/dynamo/hash.go +++ b/deploy/operator/internal/dynamo/hash.go @@ -26,7 +26,7 @@ import ( "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta1" "github.com/ai-dynamo/dynamo/deploy/operator/internal/provideroverride" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/utils/ptr" ) @@ -34,7 +34,7 @@ import ( const dgdWorkerHashPlaceholderValue = "worker-hash-placeholder" // Preserve hashes for runtimes released before version hashing. -var minimumHashedRuntimeVersion = versionoverride.Version{Major: 1, Minor: 5, Patch: 0} +var minimumHashedRuntimeVersion = runtimeversion.Version{Major: 1, Minor: 5, Patch: 0} // ComputeDGDWorkersSpecHash computes the v2 worker hash from the worker DCDs // generated by the same DGD-to-DCD path used by reconciliation. The active @@ -223,7 +223,7 @@ func resolvedRuntimeVersionForHash(component *v1beta1.DynamoComponentDeploymentS if runtime := GetDynamoContainer(component); runtime != nil { image = runtime.Image } - version, err := versionoverride.Resolve(image, component.RuntimeVersionOverride) + version, err := runtimeversion.Resolve(image, component.RuntimeVersionOverride) if err != nil || version.Compare(minimumHashedRuntimeVersion) < 0 { return "" } diff --git a/deploy/operator/internal/features/runtime/gate.go b/deploy/operator/internal/features/runtime/gate.go index 66a3aedab213..805099b09e95 100644 --- a/deploy/operator/internal/features/runtime/gate.go +++ b/deploy/operator/internal/features/runtime/gate.go @@ -5,16 +5,16 @@ package runtime -import "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" +import "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" // Gate controls runtime-version-dependent operator behavior. type Gate struct { Name string - MinRuntimeVersion versionoverride.Version + MinRuntimeVersion runtimeversion.Version } // Enabled reports whether a known runtime version meets the feature threshold. -func (g Gate) Enabled(version *versionoverride.Version) bool { +func (g Gate) Enabled(version *runtimeversion.Version) bool { if version == nil { return false } diff --git a/deploy/operator/internal/features/runtime/gate_test.go b/deploy/operator/internal/features/runtime/gate_test.go index b9ea9db53738..c3919a34b988 100644 --- a/deploy/operator/internal/features/runtime/gate_test.go +++ b/deploy/operator/internal/features/runtime/gate_test.go @@ -8,20 +8,20 @@ package runtime import ( "testing" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" ) func TestGateEnabled(t *testing.T) { t.Log("define a feature introduced by Dynamo runtime 1.4.0") gate := Gate{ Name: "TestFeature", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 4, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 4, Patch: 0}, } t.Log("define versions below, at, and above the feature threshold") tests := []struct { name string - version *versionoverride.Version + version *runtimeversion.Version want bool }{ { @@ -29,16 +29,16 @@ func TestGateEnabled(t *testing.T) { }, { name: "older runtime", - version: &versionoverride.Version{Major: 1, Minor: 3, Patch: 9}, + version: &runtimeversion.Version{Major: 1, Minor: 3, Patch: 9}, }, { name: "minimum supported runtime", - version: &versionoverride.Version{Major: 1, Minor: 4, Patch: 0}, + version: &runtimeversion.Version{Major: 1, Minor: 4, Patch: 0}, want: true, }, { name: "newer runtime", - version: &versionoverride.Version{Major: 2, Minor: 0, Patch: 0}, + version: &runtimeversion.Version{Major: 2, Minor: 0, Patch: 0}, want: true, }, } diff --git a/deploy/operator/internal/features/runtime/gates.go b/deploy/operator/internal/features/runtime/gates.go index ee21c27072c2..b77533226721 100644 --- a/deploy/operator/internal/features/runtime/gates.go +++ b/deploy/operator/internal/features/runtime/gates.go @@ -5,19 +5,19 @@ package runtime -import "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" +import "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" var ( // StrictNamespacePrefix gates operator-managed discovery isolation. StrictNamespacePrefix = Gate{ Name: "StrictNamespacePrefix", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 6, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 6, Patch: 0}, } // FrontendStartupProbe allows the sequential NATS and etcd startup connection windows. FrontendStartupProbe = Gate{ Name: "FrontendStartupProbe", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 6, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 6, Patch: 0}, } // CanaryHealthChecks gates the canary health-check rendering defaults. @@ -26,7 +26,7 @@ var ( // change an existing worker generation. CanaryHealthChecks = Gate{ Name: "CanaryHealthChecks", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 5, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 5, Patch: 0}, } // IncreasedWorkerFailureThreshold gates the worker liveness default. @@ -35,7 +35,7 @@ var ( // change an existing worker generation. IncreasedWorkerFailureThreshold = Gate{ Name: "IncreasedWorkerFailureThreshold", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 5, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 5, Patch: 0}, } // NativeRustEPP gates the native Rust EPP requirement: runtime versions at @@ -44,7 +44,7 @@ var ( // 1.5.0, the first release shipping the native Rust EPP image. NativeRustEPP = Gate{ Name: "NativeRustEPP", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 5, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 5, Patch: 0}, } // PlannerDGDComponentStatus gates Planner discovery of model, runtime, @@ -54,6 +54,6 @@ var ( // PodTemplates. PlannerDGDComponentStatus = Gate{ Name: "PlannerDGDComponentStatus", - MinRuntimeVersion: versionoverride.Version{Major: 1, Minor: 6, Patch: 0}, + MinRuntimeVersion: runtimeversion.Version{Major: 1, Minor: 6, Patch: 0}, } ) diff --git a/deploy/operator/internal/gpu/heterogeneous.go b/deploy/operator/internal/gpu/heterogeneous.go index 312f9e362695..869e6115188f 100644 --- a/deploy/operator/internal/gpu/heterogeneous.go +++ b/deploy/operator/internal/gpu/heterogeneous.go @@ -34,7 +34,7 @@ func collectOtherModels( return nil } seen := map[string]struct{}{} - for i := 0; i < nodes; i++ { + for i := range nodes { if sku, model := at(i); sku != selected { seen[model] = struct{}{} } diff --git a/deploy/operator/internal/versionoverride/versionoverride.go b/deploy/operator/internal/runtimeversion/runtimeversion.go similarity index 91% rename from deploy/operator/internal/versionoverride/versionoverride.go rename to deploy/operator/internal/runtimeversion/runtimeversion.go index 38c4682d3116..eab91c6c48d8 100644 --- a/deploy/operator/internal/versionoverride/versionoverride.go +++ b/deploy/operator/internal/runtimeversion/runtimeversion.go @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package versionoverride +package runtimeversion import ( "cmp" @@ -16,7 +16,7 @@ import ( var imageTagPattern = regexp.MustCompile(`^[vV]?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`) -// Version identifies a normalized compatibility version. +// Version identifies a runtime version by semver core. type Version struct { Major uint64 Minor uint64 @@ -38,7 +38,7 @@ func (v Version) Compare(other Version) int { ) } -// Parse returns the compatibility version represented by an explicit override. +// Parse returns the runtime version represented by an explicit override. func Parse(value string) (Version, error) { version, err := semver.StrictNewVersion(value) if err != nil { @@ -47,7 +47,7 @@ func Parse(value string) (Version, error) { return fromSemver(version), nil } -// ParseImageVersion returns a normalized compatibility version from an image tag. +// ParseImageVersion returns a normalized runtime version from an image tag. func ParseImageVersion(image string) (Version, error) { tag := imageTag(image) if tag == "" { diff --git a/deploy/operator/internal/versionoverride/versionoverride_test.go b/deploy/operator/internal/runtimeversion/runtimeversion_test.go similarity index 99% rename from deploy/operator/internal/versionoverride/versionoverride_test.go rename to deploy/operator/internal/runtimeversion/runtimeversion_test.go index 8cccb8005eef..edc26679c343 100644 --- a/deploy/operator/internal/versionoverride/versionoverride_test.go +++ b/deploy/operator/internal/runtimeversion/runtimeversion_test.go @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package versionoverride +package runtimeversion import "testing" diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_handler.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_handler.go index 7761505afa68..cf85ec325474 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_handler.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_handler.go @@ -76,15 +76,6 @@ func NewDGDRDefaulter(operatorVersion, defaultImage string) *DGDRDefaulter { // WithV1beta2 sets the defaults of v1beta2 Requests and returns the defaulter. func (d *DGDRDefaulter) WithV1beta2(options DGDRV1beta2Options) *DGDRDefaulter { - // The operator refuses to start with a configuration that does not resolve, so a failure here - // only happens to a caller that skipped ValidateAISimulateDefault: keep the image and let - // validation report what the Request lacks. - if image, version, err := resolveAISimulateDefault( - d.OperatorVersion, options.AISimulateImage, options.AISimulateVersionOverride); err == nil { - options.AISimulateImage, options.AISimulateVersionOverride = image, version - } else { - options.AISimulateImage = defaultPlannerImageFor(d.OperatorVersion, options.AISimulateImage) - } d.v1beta2 = options return d } diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go index 416fe004f280..d39b0093148f 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go @@ -20,7 +20,6 @@ package defaulting import ( "bytes" "context" - "encoding/json" "testing" nvidiacomv1beta1 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta1" @@ -34,10 +33,9 @@ import ( // v1beta1. requestVersion is the version the client used; empty means no conversion. func ctxForRequest(op admissionv1.Operation, requestVersion string) context.Context { gvk := nvidiacomv1beta1.DynamoGraphDeploymentRequestGVK - req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + req := admission.Request{ Operation: op, - Kind: metav1.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}, - }} + Kind: metav1.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}} if requestVersion != "" { req.RequestKind = &metav1.GroupVersionKind{Group: gvk.Group, Version: requestVersion, Kind: gvk.Kind} } @@ -48,52 +46,14 @@ const modelOnlyCarrier = `{"model":{"name":"Qwen/Qwen3-32B"}}` func carrierRequest() *nvidiacomv1beta1.DynamoGraphDeploymentRequest { return &nvidiacomv1beta1.DynamoGraphDeploymentRequest{ - ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "default"}, + Name: "r", Namespace: "default", Spec: nvidiacomv1beta1.DynamoGraphDeploymentRequestSpec{ V1Beta2: &apiextensionsv1.JSON{Raw: []byte(modelOnlyCarrier)}, }, } } -func aisimulateImageOf(t *testing.T, raw []byte) string { - t.Helper() - var spec map[string]any - if err := json.Unmarshal(raw, &spec); err != nil { - t.Fatalf("decode carrier: %v", err) - } - aisimulate, _ := spec["aisimulate"].(map[string]any) - image, _ := aisimulate["image"].(string) - return image -} - func TestDGDRDefaulter_RoutesByRequestedVersion(t *testing.T) { - for _, tt := range []struct { - name string - options DGDRV1beta2Options - want string - }{ - { - name: "an omitted image is derived from the operator version", - want: "nvcr.io/nvidia/ai-dynamo/dynamo-planner:1.6.0", - }, - { - name: "an explicit default image overrides derivation", - options: DGDRV1beta2Options{AISimulateImage: "registry.example/aisimulate:custom"}, - want: "registry.example/aisimulate:custom", - }, - } { - t.Run(tt.name, func(t *testing.T) { - d := NewDGDRDefaulter("1.6.0", "").WithV1beta2(tt.options) - dgdr := carrierRequest() - if err := d.Default(ctxForRequest(admissionv1.Create, "v1beta2"), dgdr); err != nil { - t.Fatalf("Default() error = %v", err) - } - if got := aisimulateImageOf(t, dgdr.Spec.V1Beta2.Raw); got != tt.want { - t.Fatalf("aisimulate.image = %q, want %q", got, tt.want) - } - }) - } - d := NewDGDRDefaulter("1.6.0", "").WithV1beta2(DGDRV1beta2Options{}) t.Run("RequestKind decides, not the converted Kind", func(t *testing.T) { @@ -104,6 +64,9 @@ func TestDGDRDefaulter_RoutesByRequestedVersion(t *testing.T) { if dgdr.Spec.Image != "" { t.Fatalf("v1beta1 image defaulting touched a v1beta2 request: %q", dgdr.Spec.Image) } + if !bytes.Contains(dgdr.Spec.V1Beta2.Raw, []byte(`"backends"`)) { + t.Fatalf("native v1beta2 defaults were not applied: %s", dgdr.Spec.V1Beta2.Raw) + } }) for _, version := range []string{"", "v1beta1", "v1alpha1"} { diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go index 37e7098880b3..2906f7b53372 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go @@ -27,20 +27,21 @@ import ( nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" admissionv1 "k8s.io/api/admission/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/utils/ptr" ) // defaultMetricV1beta2 is the scalar target an Optimize objective receives when it names none. -const defaultMetricV1beta2 = nvidiacomv1beta2.ObjectiveMetric("Throughput") +const defaultMetricV1beta2 = nvidiacomv1beta2.ObjectiveMetric("ThroughputPerGPU") // Defaults of the objects admission creates. Admission is their only owner: the API server // defaults a mutated object in the webhook's version (v1beta1, whose v1beta2 carrier is // opaque), so the v1beta2 schema carries no default markers for them. const ( - defaultStaticISLV1beta2 int32 = 1024 - defaultStaticOSLV1beta2 int32 = 128 - defaultStaticConcurrencyV1beta2 int32 = 10 - defaultMaxCandidatesV1beta2 int32 = 1 + defaultStaticInputTokensV1beta2 int32 = 1024 + defaultStaticOutputTokensV1beta2 int32 = 128 + defaultStaticConcurrencyV1beta2 int32 = 10 + defaultSLATTFTMsV1beta2 = 2000.0 + defaultSLAITLMsV1beta2 = 30.0 + defaultMaxCandidatesV1beta2 int32 = 1 ) // This file defaults the v1beta2 shape of a DynamoGraphDeploymentRequest. It is separate @@ -50,10 +51,11 @@ const ( // API version because the canonical names belong to v1beta1. // // Admission owns every v1beta2 default, and each default has this one owner: backends, -// objective.mode, the scalar metric, recommendation.maxCandidates, workload.static, the -// cluster hardware and the AISimulate image. workload.static: {} opts out of the static -// workload. objective.sla has no default: an omitted SLA stays absent. -// spec.aisimulate.parameters never changes a default. It overrides the defaulted typed +// objective.mode, the scalar metric, objective.sla, recommendation.maxCandidates, +// workload.static and the cluster hardware. workload.static: {} opts out of the static +// workload; a nonempty static object receives any missing inputTokens, outputTokens and load +// defaults. objective.sla: {} opts out of the latency defaults. +// spec.aisimulate.inputOverrides never changes a default. It overrides the defaulted typed // values later, when the input is lowered. // defaultDynamoGraphDeploymentRequestV1beta2 defaults the v1beta2 spec that dgdr carries in @@ -123,10 +125,6 @@ func decodeOldSpecV1beta2( type dynamoGraphDeploymentRequestV1beta2DefaultOptions struct { // hardware is the GPU identity and budget discovered from the cluster. hardware hardwareDefaultsV1beta2 - // aisimulateImage is the operator's resolved AISimulate image. - aisimulateImage string - // aisimulateVersionOverride belongs to aisimulateImage. - aisimulateVersionOverride string } // hardwareDefaultsV1beta2 is the hardware the cluster can offer for the values a Request omits. @@ -180,15 +178,6 @@ func defaultNativeSearchInputV1beta2( } defaultObjectiveSpecV1beta2(input.Objective) - // The operator image is persisted with the Request, so spec.aisimulate is created to - // carry it. - if input.AISimulate == nil && opts.aisimulateImage != "" { - input.AISimulate = &nvidiacomv1beta2.AISimulateSpec{} - } - if input.AISimulate != nil { - defaultAISimulateSpecV1beta2(input.AISimulate, opts.aisimulateImage, opts.aisimulateVersionOverride) - } - if input.Recommendation == nil { input.Recommendation = &nvidiacomv1beta2.RecommendationSpec{} } @@ -208,52 +197,59 @@ func defaultHardwareSpecV1beta2(hardware *nvidiacomv1beta2.HardwareSpec, default // SKU and let admission size the budget. func defaultGPUHardwareSpecV1beta2(gpu *nvidiacomv1beta2.GPUHardwareSpec, defaults hardwareDefaultsV1beta2) { if gpu.SKU == nil && defaults.sku != "" { - gpu.SKU = ptr.To(defaults.sku) + gpu.SKU = new(defaults.sku) } if gpu.Budget == nil && defaults.budget > 0 { - gpu.Budget = ptr.To(defaults.budget) + gpu.Budget = new(defaults.budget) } } func defaultWorkloadSpecV1beta2(workload *nvidiacomv1beta2.WorkloadSpec) { - // An explicit static: {} is the opt-out, so the static defaults apply only to the object - // created here. if workload.Static == nil { workload.Static = &nvidiacomv1beta2.StaticWorkloadSpec{} - defaultStaticWorkloadSpecV1beta2(workload.Static) + } else if workload.Static.InputTokens == nil && workload.Static.OutputTokens == nil && workload.Static.Load == nil { + // An explicit empty object is the one static-workload opt-out. + return } + defaultStaticWorkloadSpecV1beta2(workload.Static) } +// defaultStaticWorkloadSpecV1beta2 completes a static workload with every omitted default. func defaultStaticWorkloadSpecV1beta2(static *nvidiacomv1beta2.StaticWorkloadSpec) { - static.ISL = ptr.To(defaultStaticISLV1beta2) - static.OSL = ptr.To(defaultStaticOSLV1beta2) - static.Concurrency = ptr.To(defaultStaticConcurrencyV1beta2) + if static.InputTokens == nil { + static.InputTokens = new(defaultStaticInputTokensV1beta2) + } + if static.OutputTokens == nil { + static.OutputTokens = new(defaultStaticOutputTokensV1beta2) + } + if static.Load == nil { + static.Load = &nvidiacomv1beta2.StaticLoadSpec{ + Concurrency: new(defaultStaticConcurrencyV1beta2), + } + } } -// defaultObjectiveSpecV1beta2 defaults the mode and gives an Optimize objective without a -// metric the default scalar target. It reads the typed objective alone: a goal.target in -// spec.aisimulate.parameters overrides the completed typed value when the input is lowered, -// so it never suppresses this default. +// defaultObjectiveSpecV1beta2 defaults the mode, the Optimize metric and an absent SLA. +// Existing SLA objects are preserved, including the empty opt-out. It reads the typed objective +// alone: spec.aisimulate.inputOverrides overrides the completed typed values when the input is +// lowered, so it never suppresses these defaults. func defaultObjectiveSpecV1beta2(objective *nvidiacomv1beta2.ObjectiveSpec) { if objective.Mode == "" { objective.Mode = nvidiacomv1beta2.ObjectiveModeOptimize } if objective.Mode == nvidiacomv1beta2.ObjectiveModeOptimize && objective.Metric == nil { - objective.Metric = ptr.To(defaultMetricV1beta2) + objective.Metric = new(defaultMetricV1beta2) + } + if objective.SLA == nil { + objective.SLA = &nvidiacomv1beta2.ObjectiveSLASpec{} + defaultObjectiveSLASpecV1beta2(objective.SLA) } } -// defaultAISimulateSpecV1beta2 persists the operator's AISimulate artifact as one pair. The -// override follows the image: it is applied only when admission supplies the image. A caller's -// image derives its own version or brings its own override, and a caller's override always wins. -func defaultAISimulateSpecV1beta2(aisimulate *nvidiacomv1beta2.AISimulateSpec, image, versionOverride string) { - if aisimulate.Image != "" || image == "" { - return - } - aisimulate.Image = image - if aisimulate.VersionOverride == "" { - aisimulate.VersionOverride = versionOverride - } +// defaultObjectiveSLASpecV1beta2 fills the whole-object SLA default admission creates. +func defaultObjectiveSLASpecV1beta2(sla *nvidiacomv1beta2.ObjectiveSLASpec) { + sla.TTFTMs = new(defaultSLATTFTMsV1beta2) + sla.ITLMs = new(defaultSLAITLMsV1beta2) } func defaultRecommendationSpecV1beta2(recommendation *nvidiacomv1beta2.RecommendationSpec) { diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact.go deleted file mode 100644 index 613a3f9d0ca2..000000000000 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact.go +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 defaulting - -import ( - "fmt" - - "github.com/ai-dynamo/dynamo/deploy/operator/internal/controller/aisimulate" -) - -// resolveAISimulateDefault returns the AISimulate artifact admission persists as one pair: the -// configured planner image, or the official one derived from the operator version when none is -// configured, and the AISimulate version it carries. The explicit override wins, the derived image -// included; otherwise the version is the one bundled in the Dynamo release named by the image tag -// (aisimulate.ResolveVersion). Both are empty when there is nothing to persist: no image is -// configured and the operator version names no release. An override then has no image to pair with. -func resolveAISimulateDefault(operatorVersion, image, versionOverride string) (resolvedImage, version string, err error) { - resolvedImage = defaultPlannerImageFor(operatorVersion, image) - if resolvedImage == "" { - if versionOverride != "" { - return "", "", fmt.Errorf("the AISimulate default version override needs an image: operator version %q "+ - "names no release to derive the planner image from; set the AISimulate default image", operatorVersion) - } - return "", "", nil - } - version, err = aisimulate.ResolveVersion(resolvedImage, versionOverride) - if err != nil { - return "", "", fmt.Errorf("the AISimulate default image %q cannot be used as it is: %w; "+ - "set the AISimulate default version override to the AISimulate version it bundles", resolvedImage, err) - } - return resolvedImage, version, nil -} - -// ValidateAISimulateDefault rejects an operator configuration whose default AISimulate artifact -// admission could not persist: an image, configured or derived from the operator version, whose -// Dynamo release has no bundled AISimulate version in the release contract needs the override that -// names it, and an override without any image to pair with is rejected too. An override with an -// empty image configuration is valid: it pairs with the official planner image. -func ValidateAISimulateDefault(operatorVersion, image, versionOverride string) error { - _, _, err := resolveAISimulateDefault(operatorVersion, image, versionOverride) - return err -} diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact_test.go deleted file mode 100644 index 9ec9339eee89..000000000000 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact_test.go +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - * - * 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 defaulting - -import ( - "testing" - - nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/controller/aisimulate" -) - -func TestDefaultAISimulateFollowsTheOwnershipOfTheImage(t *testing.T) { - const image = "registry.example/aisimulate:nightly" - tests := []struct { - name string - spec nvidiacomv1beta2.AISimulateSpec - wantImage string - wantOverride string - }{ - {"the operator's pair is persisted together", nvidiacomv1beta2.AISimulateSpec{}, image, "1.2.0"}, - {"a caller's image gets no operator override", - nvidiacomv1beta2.AISimulateSpec{Image: "example/own:v1.0.0"}, "example/own:v1.0.0", ""}, - {"a caller's override wins next to the operator's image", - nvidiacomv1beta2.AISimulateSpec{VersionOverride: "2.0.0"}, image, "2.0.0"}, - {"a caller's pair is kept", - nvidiacomv1beta2.AISimulateSpec{Image: "example/own:x", VersionOverride: "3.0.0"}, "example/own:x", "3.0.0"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - spec := tt.spec - defaultAISimulateSpecV1beta2(&spec, image, "1.2.0") - if spec.Image != tt.wantImage || spec.VersionOverride != tt.wantOverride { - t.Fatalf("got %q/%q, want %q/%q", spec.Image, spec.VersionOverride, tt.wantImage, tt.wantOverride) - } - }) - } -} - -func TestValidateAISimulateDefault(t *testing.T) { - const ( - mapped = "registry.example/aisimulate:1.6.0" - unmapped = "registry.example/aisimulate:1.2.3" - nightly = "registry.example/aisimulate:nightly" - bundled = "0.13.0" - unmappedV = "0.9.0" - ) - tests := []struct { - name, operatorVersion, image, override string - wantErr bool - }{ - {"the derived planner image of a mapped release", "1.6.0", "", "", false}, - {"the derived planner image of a prerelease", "1.6.0-rc.1", "", "", false}, - {"the derived planner image of a release without an entry", "1.99.0", "", "", true}, - {"an override rescues the derived image", "1.99.0", "", unmappedV, false}, - {"an override with an empty image configuration", "1.6.0", "", bundled, false}, - {"an operator version that names no release", "unknown", "", "", false}, - {"an override with nothing to pair with", "unknown", "", bundled, true}, - {"a mapped tag", "1.6.0", mapped, "", false}, - {"a mapped tag with an override", "1.6.0", mapped, "0.13.1", false}, - {"a release without an entry", "1.6.0", unmapped, "", true}, - {"a release without an entry, with its override", "1.6.0", unmapped, unmappedV, false}, - {"a non-release tag with its override", "1.6.0", nightly, bundled, false}, - {"a non-release tag without an override", "1.6.0", nightly, "", true}, - {"an override that is not a version", "1.6.0", nightly, "bad", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := ValidateAISimulateDefault(tt.operatorVersion, tt.image, tt.override); (err != nil) != tt.wantErr { - t.Fatalf("error = %v, want error: %v", err, tt.wantErr) - } - }) - } -} - -func TestWithV1beta2PersistsThePlannerImageWithItsBundledAISimulateVersion(t *testing.T) { - derived := defaultImage + ":1.6.0" - bundled, err := aisimulate.ResolveVersion(derived, "") - if err != nil { - t.Fatalf("the release contract must map the operator version used here: %v", err) - } - tests := []struct { - name string - options DGDRV1beta2Options - wantImage string - wantOverride string - }{ - {"the derived image gets the bundled version", DGDRV1beta2Options{}, derived, bundled}, - {"the explicit override wins with the derived image", - DGDRV1beta2Options{AISimulateVersionOverride: "0.13.1"}, derived, "0.13.1"}, - {"a configured mapped image gets the bundled version", - DGDRV1beta2Options{AISimulateImage: "registry.example/aisimulate:1.6.0"}, - "registry.example/aisimulate:1.6.0", bundled}, - {"a configured nightly image keeps its override", - DGDRV1beta2Options{AISimulateImage: "registry.example/aisimulate:nightly", AISimulateVersionOverride: "0.13.0"}, - "registry.example/aisimulate:nightly", "0.13.0"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := NewDGDRDefaulter("1.6.0", "").WithV1beta2(tt.options).v1beta2 - if got.AISimulateImage != tt.wantImage || got.AISimulateVersionOverride != tt.wantOverride { - t.Fatalf("got %q/%q, want %q/%q", got.AISimulateImage, got.AISimulateVersionOverride, tt.wantImage, tt.wantOverride) - } - }) - } -} diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_hardware_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_hardware_test.go index d4cbbc621047..c797d4b6ec23 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_hardware_test.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_hardware_test.go @@ -57,16 +57,21 @@ func TestResolveHardwareCompletesTheTypedHardwareOrRejects(t *testing.T) { hardwareOfV1beta2(str("h200_sxm"), nil), h200x64, nil, "", 32, 1, "h200_sxm", ""}, {"a budget alone keeps the budget and discovers the SKU", hardwareOfV1beta2(nil, num(4)), h200x8, nil, "h200_sxm", 0, 1, "", ""}, {"an explicit budget above the cluster size is kept", hardwareOfV1beta2(nil, num(64)), h200x8, nil, "h200_sxm", 0, 1, "", ""}, - {"an explicit budget above the cap is kept", hardwareOfV1beta2(str("h200_sxm"), num(64)), h200x8, nil, "", 0, 0, "", ""}, - {"a SKU and a budget never reach the cluster", hardwareOfV1beta2(str("h100_sxm"), num(4)), gpu.GPUInfo{}, errors.New("down"), "", 0, 0, "", ""}, + {"an explicit budget above the cap is kept", hardwareOfV1beta2(str("h200_sxm"), num(64)), h200x8, nil, "", 0, 1, "h200_sxm", ""}, + {"a SKU and a budget verify the SKU", hardwareOfV1beta2(str("h100_sxm"), num(4)), + gpu.GPUInfo{System: "h100_sxm", GPUsPerNode: 8, NodesWithGPUs: 1}, + nil, "", 0, 1, "h100_sxm", ""}, + {"complete hardware rejects failed verification", hardwareOfV1beta2(str("h100_sxm"), num(4)), + gpu.GPUInfo{}, errors.New("down"), "", 0, 1, "h100_sxm", + `spec.hardware.gpu.sku: Invalid value: "h100_sxm": could not be verified against the cluster's GPUs (down)`}, {"a failed discovery rejects", nil, gpu.GPUInfo{}, errors.New("no GPUs found"), "", 0, 1, "", "spec.hardware.gpu: Required value: the cluster's GPUs could not be discovered (no GPUs found)"}, {"a GPU model the catalog does not know rejects", nil, gpu.GPUInfo{Model: "Mystery GPU", GPUsPerNode: 8, NodesWithGPUs: 1}, nil, "", 0, 1, "", `spec.hardware.gpu.sku: Required value: the cluster's GPU model "Mystery GPU" maps to no AISimulate SKU`}, - {"no GPUs of the SKU reject the missing budget", hardwareOfV1beta2(str("h100_sxm"), nil), + {"no GPUs of the SKU reject the SKU", hardwareOfV1beta2(str("h100_sxm"), nil), gpu.GPUInfo{System: "h100_sxm"}, nil, "", 0, 1, "h100_sxm", - `spec.hardware.gpu.budget: Required value: no GPUs of SKU "h100_sxm" were found in the cluster`}, + `spec.hardware.gpu.sku: Invalid value: "h100_sxm": no GPUs of this SKU were found in the cluster`}, {"a heterogeneous cluster rejects an omitted SKU", nil, gpu.GPUInfo{System: "h200_sxm", Model: "H200", GPUsPerNode: 8, NodesWithGPUs: 1, OtherModels: []string{"H100"}}, nil, "", 0, 1, "", diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go index fe7bfb6c36f9..d4c02d9d5f10 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go @@ -54,15 +54,6 @@ type HardwareDiscoverer interface { // DGDRV1beta2Options configures the defaults of v1beta2 Requests. type DGDRV1beta2Options struct { - // AISimulateImage overrides the version-matched Dynamo planner image persisted - // into spec.aisimulate.image when the caller omits it. - AISimulateImage string - // AISimulateVersionOverride is the AISimulate version bundled in AISimulateImage, for an image - // whose Dynamo release the release contract does not map (a nightly or custom planner image). - // Empty maps the image's release. WithV1beta2 completes it, so admission persists the image - // together with its AISimulate version, and only next to an image it supplies itself, never - // next to an image the caller chose. - AISimulateVersionOverride string // Hardware discovers the cluster's GPUs. Nil disables hardware defaulting, and // validation then requires the hardware to be spelled out. Hardware HardwareDiscoverer @@ -85,15 +76,11 @@ func (o DGDRV1beta2Options) resolveDefaults( if err != nil { return dynamoGraphDeploymentRequestV1beta2DefaultOptions{}, err } - return dynamoGraphDeploymentRequestV1beta2DefaultOptions{ - hardware: hardware, - aisimulateImage: o.AISimulateImage, - aisimulateVersionOverride: o.AISimulateVersionOverride, - }, nil + return dynamoGraphDeploymentRequestV1beta2DefaultOptions{hardware: hardware}, nil } -// resolveHardware returns the SKU and budget the typed spec omits. It asks the cluster only -// when something is missing, restricted to the typed SKU when there is one. +// resolveHardware returns the SKU and budget the typed spec omits and verifies an explicit SKU +// against the cluster. Discovery is restricted to the typed SKU when there is one. // // Discovery runs on CREATE and on an UPDATE that changes spec.hardware. A Request stays // mutable and every Run snapshots it, so a caller may change or remove typed hardware before @@ -101,13 +88,13 @@ func (o DGDRV1beta2Options) resolveDefaults( // never asks the cluster: a Request created while discovery had nothing to offer must not // pick hardware up once discovery recovers, which would change its spec and generation behind // the writer's back. oldSpec is the stored spec on UPDATE and nil otherwise. The decision reads -// the typed hardware alone, never spec.aisimulate.parameters. +// the typed hardware alone, never spec.aisimulate.inputOverrides. // // After a successful return the typed hardware is complete. An omitted SKU becomes the // cluster's AISimulate SKU, and an omitted budget becomes min(matching GPUs, cap). An explicit // budget is authoritative: it is never capped, whatever the cluster holds. What the cluster // cannot supply is rejected on the field that fixes it: a failed discovery, a GPU model the -// AISimulate catalog does not know, and a SKU without any matching GPU. +// AISimulate catalog does not know, and an explicit SKU without any matching GPU. func (o DGDRV1beta2Options) resolveHardware( ctx context.Context, operation admissionv1.Operation, @@ -126,10 +113,6 @@ func (o DGDRV1beta2Options) resolveHardware( } hasBudget = spec.Hardware.GPU.Budget != nil } - if sku != "" && hasBudget { - return hardwareDefaultsV1beta2{}, nil - } - timeout := o.HardwareDiscoveryTimeout if timeout <= 0 { timeout = defaultHardwareDiscoveryTimeout @@ -140,6 +123,13 @@ func (o DGDRV1beta2Options) resolveHardware( gpuPath := field.NewPath("spec", "hardware", "gpu") found, err := o.Hardware.Discover(ctx, nvidiacomv1beta1.GPUSKUType(sku)) if err != nil { + if sku != "" { + return hardwareDefaultsV1beta2{}, rejectHardwareV1beta2(field.Invalid( + gpuPath.Child("sku"), + sku, + fmt.Sprintf("could not be verified against the cluster's GPUs (%v)", err), + )) + } return hardwareDefaultsV1beta2{}, rejectHardwareV1beta2(field.Required(gpuPath, fmt.Sprintf( "the cluster's GPUs could not be discovered (%v); set spec.hardware.gpu.sku and spec.hardware.gpu.budget", err))) } @@ -163,12 +153,21 @@ func (o DGDRV1beta2Options) resolveHardware( } defaults.sku = sku } + + // A filtered discovery with no GPUs means the caller pinned a SKU the cluster cannot run. + var gpus int + if found != nil { + // TODO(#17192): Use the discovered aggregate GPU count so mixed node sizes of one SKU are counted correctly. + gpus = found.GPUsPerNode * found.NodesWithGPUs + } + if spec.Hardware != nil && spec.Hardware.GPU != nil && spec.Hardware.GPU.SKU != nil && gpus <= 0 { + return hardwareDefaultsV1beta2{}, rejectHardwareV1beta2(field.Invalid( + gpuPath.Child("sku"), + sku, + "no GPUs of this SKU were found in the cluster", + )) + } if !hasBudget { - var gpus int - if found != nil { - // TODO(#17192): Use the discovered aggregate GPU count so mixed node sizes of one SKU are counted correctly. - gpus = found.GPUsPerNode * found.NodesWithGPUs - } if gpus <= 0 { return hardwareDefaultsV1beta2{}, rejectHardwareV1beta2(field.Required(gpuPath.Child("budget"), fmt.Sprintf( "no GPUs of SKU %q were found in the cluster; set spec.hardware.gpu.budget", sku))) @@ -192,8 +191,7 @@ func rejectHardwareV1beta2(err *field.Error) error { return &hardwareRejectionV1 // admissionVerdictV1beta2 turns a rejection into the invalid-object verdict validation uses, so a // client sees the field and its cause. Any other error is returned as it is. func admissionVerdictV1beta2(dgdr *nvidiacomv1beta1.DynamoGraphDeploymentRequest, err error) error { - var rejection *hardwareRejectionV1beta2 - if errors.As(err, &rejection) { + if rejection, ok := errors.AsType[*hardwareRejectionV1beta2](err); ok { return apierrors.NewInvalid(nvidiacomv1beta1.DynamoGraphDeploymentRequestGVK.GroupKind(), dgdr.Name, field.ErrorList{rejection.err}) } diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go index 25b6dfbf90b5..ee4d4f7b01c1 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go @@ -30,8 +30,6 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" ) -func v1beta2Ptr[T any](v T) *T { return &v } - type fakeDiscoverer struct { found gpu.GPUInfo err error @@ -64,13 +62,13 @@ func TestResolveHardwareV1beta2(t *testing.T) { t.Fatalf("got %+v", got) } }) - t.Run("complete hardware never asks the cluster", func(t *testing.T) { - d := &fakeDiscoverer{found: gpu.GPUInfo{System: "h100_sxm", GPUsPerNode: 4, NodesWithGPUs: 1}} - got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(v1beta2Ptr("b200_sxm"), v1beta2Ptr(int32(2)))) + t.Run("complete hardware verifies its SKU against the cluster", func(t *testing.T) { + d := &fakeDiscoverer{found: gpu.GPUInfo{System: "b200_sxm", GPUsPerNode: 4, NodesWithGPUs: 1}} + got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(new("b200_sxm"), new(int32(2)))) if err != nil { t.Fatal(err) } - if d.calls != 0 || !(got == hardwareDefaultsV1beta2{}) { + if d.calls != 1 || d.filter != "b200_sxm" || !(got == hardwareDefaultsV1beta2{}) { t.Fatalf("calls=%d got=%+v", d.calls, got) } }) @@ -89,7 +87,7 @@ func TestResolveHardwareV1beta2(t *testing.T) { }) t.Run("a pinned SKU filters discovery and keeps its value", func(t *testing.T) { d := &fakeDiscoverer{found: gpu.GPUInfo{System: "b200_sxm", GPUsPerNode: 4, NodesWithGPUs: 1}} - got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(v1beta2Ptr("b200_sxm"), nil)) + got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(new("b200_sxm"), nil)) if err != nil { t.Fatal(err) } @@ -99,7 +97,7 @@ func TestResolveHardwareV1beta2(t *testing.T) { }) t.Run("a pinned budget keeps its value", func(t *testing.T) { d := &fakeDiscoverer{found: gpu.GPUInfo{System: "h200_sxm", GPUsPerNode: 64, NodesWithGPUs: 1}} - got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(nil, v1beta2Ptr(int32(3)))) + got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(nil, new(int32(3)))) if err != nil { t.Fatal(err) } @@ -127,12 +125,12 @@ func TestResolveHardwareV1beta2(t *testing.T) { func TestResolveDefaultsV1beta2(t *testing.T) { d := &fakeDiscoverer{found: gpu.GPUInfo{System: "h200_sxm", GPUsPerNode: 8, NodesWithGPUs: 1}} - got, err := DGDRV1beta2Options{Hardware: d, AISimulateImage: "registry.example.com/aisimulate:1.2.0"}. + got, err := DGDRV1beta2Options{Hardware: d}. resolveDefaults(context.Background(), admissionv1.Create, nil, modelOnlySpecV1beta2()) if err != nil { t.Fatal(err) } - if got.aisimulateImage != "registry.example.com/aisimulate:1.2.0" || got.hardware.sku != "h200_sxm" || got.hardware.budget != 8 { + if got.hardware.sku != "h200_sxm" || got.hardware.budget != 8 { t.Fatalf("got %+v", got) } } @@ -150,8 +148,7 @@ func v1beta2Envelope(t *testing.T, spec *nvidiacomv1beta2.DynamoGraphDeploymentR func TestDefaultEnvelopeV1beta2(t *testing.T) { options := DGDRV1beta2Options{ - AISimulateImage: "registry.example.com/aisimulate:1.2.0", - Hardware: &fakeDiscoverer{found: gpu.GPUInfo{System: "h200_sxm", GPUsPerNode: 8, NodesWithGPUs: 1}}, + Hardware: &fakeDiscoverer{found: gpu.GPUInfo{System: "h200_sxm", GPUsPerNode: 8, NodesWithGPUs: 1}}, } dgdr := v1beta2Envelope(t, modelOnlySpecV1beta2()) @@ -165,9 +162,6 @@ func TestDefaultEnvelopeV1beta2(t *testing.T) { if got.Hardware == nil || got.Hardware.GPU == nil || *got.Hardware.GPU.SKU != "h200_sxm" || *got.Hardware.GPU.Budget != 8 { t.Fatalf("hardware not defaulted: %+v", got.Hardware) } - if got.AISimulate == nil || got.AISimulate.Image != "registry.example.com/aisimulate:1.2.0" { - t.Fatalf("image not defaulted: %+v", got.AISimulate) - } if got.Objective == nil || got.Objective.Metric == nil { t.Fatalf("objective metric not defaulted: %+v", got.Objective) } @@ -202,7 +196,7 @@ func TestHardwareIsNeverDiscoveredOnOtherOperations(t *testing.T) { for _, op := range []admissionv1.Operation{admissionv1.Delete, admissionv1.Connect, ""} { t.Run("operation "+string(op), func(t *testing.T) { d := &fakeDiscoverer{found: gpu.GPUInfo{System: "h200_sxm", GPUsPerNode: 8, NodesWithGPUs: 1}} - old := hardwareSpecV1beta2(v1beta2Ptr("h100_sxm"), v1beta2Ptr(int32(4))) + old := hardwareSpecV1beta2(new("h100_sxm"), new(int32(4))) got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), op, old, hardwareSpecV1beta2(nil, nil)) if err != nil { t.Fatal(err) @@ -216,7 +210,7 @@ func TestHardwareIsNeverDiscoveredOnOtherOperations(t *testing.T) { func TestResolveHardwareByOperationV1beta2(t *testing.T) { full := func() *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec { - return hardwareSpecV1beta2(v1beta2Ptr("h100_sxm"), v1beta2Ptr(int32(4))) + return hardwareSpecV1beta2(new("h100_sxm"), new(int32(4))) } tests := []struct { name string @@ -231,22 +225,22 @@ func TestResolveHardwareByOperationV1beta2(t *testing.T) { {"metadata-only update without hardware does not discover", admissionv1.Update, hardwareSpecV1beta2(nil, nil), hardwareSpecV1beta2(nil, nil), hardwareDefaultsV1beta2{}, 0, ""}, {"update with unchanged incomplete hardware does not discover", admissionv1.Update, - hardwareSpecV1beta2(v1beta2Ptr("h100_sxm"), nil), hardwareSpecV1beta2(v1beta2Ptr("h100_sxm"), nil), + hardwareSpecV1beta2(new("h100_sxm"), nil), hardwareSpecV1beta2(new("h100_sxm"), nil), hardwareDefaultsV1beta2{}, 0, ""}, {"update without a stored spec does not discover", admissionv1.Update, nil, hardwareSpecV1beta2(nil, nil), hardwareDefaultsV1beta2{}, 0, ""}, {"removing only the budget discovers only the budget", admissionv1.Update, - full(), hardwareSpecV1beta2(v1beta2Ptr("h100_sxm"), nil), + full(), hardwareSpecV1beta2(new("h100_sxm"), nil), hardwareDefaultsV1beta2{budget: 8}, 1, "h100_sxm"}, {"removing only the SKU discovers only the SKU", admissionv1.Update, - full(), hardwareSpecV1beta2(nil, v1beta2Ptr(int32(4))), + full(), hardwareSpecV1beta2(nil, new(int32(4))), hardwareDefaultsV1beta2{sku: "h200_sxm"}, 1, ""}, {"removing all typed hardware discovers both", admissionv1.Update, full(), hardwareSpecV1beta2(nil, nil), hardwareDefaultsV1beta2{sku: "h200_sxm", budget: 8}, 1, ""}, - {"changing to complete hardware does not discover", admissionv1.Update, - full(), hardwareSpecV1beta2(v1beta2Ptr("b200_sxm"), v1beta2Ptr(int32(2))), - hardwareDefaultsV1beta2{}, 0, ""}, + {"changing to complete hardware verifies the new SKU", admissionv1.Update, + full(), hardwareSpecV1beta2(new("b200_sxm"), new(int32(2))), + hardwareDefaultsV1beta2{}, 1, "b200_sxm"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -264,7 +258,7 @@ func TestResolveHardwareByOperationV1beta2(t *testing.T) { } func TestDecodeOldSpecV1beta2(t *testing.T) { - old, err := json.Marshal(v1beta2Envelope(t, hardwareSpecV1beta2(v1beta2Ptr("h100_sxm"), nil))) + old, err := json.Marshal(v1beta2Envelope(t, hardwareSpecV1beta2(new("h100_sxm"), nil))) if err != nil { t.Fatal(err) } diff --git a/deploy/operator/internal/webhook/request_version_test.go b/deploy/operator/internal/webhook/request_version_test.go index 837bf346d0a4..312f9ce7f87c 100644 --- a/deploy/operator/internal/webhook/request_version_test.go +++ b/deploy/operator/internal/webhook/request_version_test.go @@ -20,7 +20,6 @@ package webhook import ( "testing" - admissionv1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) @@ -28,9 +27,8 @@ import ( // requestWithKinds returns a request whose stored kind is v1beta1, the version every webhook // receives after conversion, and whose original request kind is requestKind (empty for none). func requestWithKinds(requestKind string) admission.Request { - req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ - Kind: metav1.GroupVersionKind{Group: "nvidia.com", Version: "v1beta1", Kind: "DynamoGraphDeploymentRequest"}, - }} + req := admission.Request{ + Kind: metav1.GroupVersionKind{Group: "nvidia.com", Version: "v1beta1", Kind: "DynamoGraphDeploymentRequest"}} if requestKind != "" { req.RequestKind = &metav1.GroupVersionKind{Group: "nvidia.com", Version: requestKind, Kind: "DynamoGraphDeploymentRequest"} } diff --git a/deploy/operator/internal/webhook/setup/setup.go b/deploy/operator/internal/webhook/setup/setup.go index 268fe4d312c5..b539ff1686b2 100644 --- a/deploy/operator/internal/webhook/setup/setup.go +++ b/deploy/operator/internal/webhook/setup/setup.go @@ -25,8 +25,7 @@ type Options struct { OperatorVersion string // DGDRV1beta1 configures the defaults of v1beta1 Requests. DGDRV1beta1 webhookdefaulting.DGDRV1beta1Options - // DGDRV1beta2 configures the defaults of v1beta2 Requests: the AISimulate runner - // image and the cluster hardware discovery. + // DGDRV1beta2 configures cluster-derived defaults of v1beta2 Requests. DGDRV1beta2 webhookdefaulting.DGDRV1beta2Options OperatorPrincipal string Gate features.Gate diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest.go index 85e6b28a1695..e2c0713481c9 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest.go @@ -27,7 +27,7 @@ import ( nvidiacomv1beta1 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta1" "github.com/ai-dynamo/dynamo/deploy/operator/internal/dgdoverride" "github.com/ai-dynamo/dynamo/deploy/operator/internal/features" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" internalwebhook "github.com/ai-dynamo/dynamo/deploy/operator/internal/webhook" apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -346,7 +346,7 @@ func dgdrRuntimeVersionOverrideRequired(spec *nvidiacomv1beta1.DynamoGraphDeploy if spec.Image == "" || spec.RuntimeVersionOverride != "" { return false } - _, err := versionoverride.ParseImageVersion(spec.Image) + _, err := runtimeversion.ParseImageVersion(spec.Image) return err != nil } diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_carrier_envtest_test.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_carrier_envtest_test.go index f8f8eac32574..2503a0da41e2 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_carrier_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_carrier_envtest_test.go @@ -31,6 +31,7 @@ func carrierRoundTrip(t *testing.T, ownerFixture, otherFixture runtime.Object, c t.Helper() env := admissionEnv.ForTest(t) admissionGate.set(features.Gates{GPUDiscovery: true}) + setAdmissionH100Topology() warnings := &warningRecorder{} ownerClient := newAdmissionResourceClient(t, env, ownerFixture, "", warnings) otherClient := newAdmissionResourceClient(t, env, otherFixture, "", warnings) diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go index 557129b380e4..ca5093f39251 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go @@ -21,7 +21,7 @@ import ( "encoding/json" nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/controller/aisimulate" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/aisimulate" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/util/validation/field" ) @@ -39,44 +39,25 @@ func validateDynamoGraphDeploymentRequestEnvelopeV1beta2( if err := json.Unmarshal(raw.Raw, &spec); err != nil { return field.ErrorList{field.Invalid(fldPath, "", "must be a valid v1beta2 spec: "+err.Error())} } - return validateDynamoGraphDeploymentRequestV1beta2(&spec) -} -// validateDynamoGraphDeploymentRequestV1beta2 validates a defaulted v1beta2 Request spec: the -// typed fields and the image and version selection. spec.aisimulate.parameters is opaque here: the -// CRD types it as an object, and the validation of its content belongs to the schema of the -// AISimulate release the Request selects. -func validateDynamoGraphDeploymentRequestV1beta2(spec *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec) field.ErrorList { - return validateDynamoGraphDeploymentRequestSpecV1beta2(spec, field.NewPath("spec")) + allErrs := validateDynamoGraphDeploymentRequestSpecV1beta2(&spec, field.NewPath("spec")) + if _, err := aisimulate.Lower(&spec.NativeSearchInput); err != nil { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec"), + "", + "cannot be lowered to the public AISimulate recommendation input: "+err.Error(), + )) + } + return allErrs } -// validateDynamoGraphDeploymentRequestSpecV1beta2 validates the typed rules that the source -// schema and CEL cannot express. The generated OpenAPI schema and CEL of the v1beta2 API have -// already run before conversion reaches this webhook, so they are not repeated here: native -// model presence, the model name length, backend uniqueness and the kvLoadRatio range order. -// spec and fldPath must not be nil. +// validateDynamoGraphDeploymentRequestSpecV1beta2 validates spec. spec and fldPath must not be nil. func validateDynamoGraphDeploymentRequestSpecV1beta2( spec *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec, fldPath *field.Path, ) field.ErrorList { - return validateAISimulateSpecV1beta2(spec.AISimulate, fldPath.Child("aisimulate")) -} - -// validateAISimulateSpecV1beta2 validates the execution artifact. Admission persists the -// operator's default image, so a Request that still has none was created while the operator -// had no default image configured. spec may be nil. -func validateAISimulateSpecV1beta2(spec *nvidiacomv1beta2.AISimulateSpec, fldPath *field.Path) field.ErrorList { - if spec == nil || spec.Image == "" { - return field.ErrorList{field.Required(fldPath.Child("image"), - "an AISimulate image is required; set it, or configure the operator's default AISimulate image")} - } - if _, err := aisimulate.ResolveVersion(spec.Image, spec.VersionOverride); err != nil { - if spec.VersionOverride != "" { - return field.ErrorList{field.Invalid(fldPath.Child("versionOverride"), spec.VersionOverride, - "must be a semantic version such as \"0.13.0\"")} - } - return field.ErrorList{field.Required(fldPath.Child("versionOverride"), - "is required when the tag of spec.aisimulate.image is not a Dynamo release with a bundled AISimulate version")} + if spec.AISimulate == nil { + return nil } - return nil + return validateAISimulateSpec(spec.AISimulate, fldPath.Child("aisimulate")) } diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_input_overrides.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_input_overrides.go new file mode 100644 index 000000000000..bd955a3e54b5 --- /dev/null +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_input_overrides.go @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * 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 validation + +import ( + nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/aisimulate" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// validateAISimulateSpec validates the Request's native AISimulate overrides. +func validateAISimulateSpec( + input *nvidiacomv1beta2.AISimulateSpec, + fldPath *field.Path, +) field.ErrorList { + // Use the lowering package's decoder as the single interpretation of RawExtension. + overrides, err := aisimulate.InputOverrides(&nvidiacomv1beta2.NativeSearchInput{AISimulate: input}) + if err != nil || overrides == nil { + // The lowering validation reports malformed override documents once. + return nil + } + + dgdrRunValue, found := overrides["dgdr_run"] + if !found { + return nil + } + dgdrRun, ok := dgdrRunValue.(map[string]any) + if !ok { + return field.ErrorList{field.Forbidden( + fldPath.Child("inputOverrides", "dgdr_run"), + "must be an object because the operator owns required execution fields within it", + )} + } + + // Search overrides remain authoritative; only execution values coupled to the + // controller-owned Job and shared volume are reserved. + reserved := []string{ + "derive_runtime_images", + "max_candidates", + "name", + "namespace", + "num_gpus_per_node", + "runtime_image", + "runtime_version_override", + "snapshot_dir", + } + allErrs := field.ErrorList{} + for _, key := range reserved { + if _, found := dgdrRun[key]; found { + allErrs = append(allErrs, field.Forbidden( + fldPath.Child("inputOverrides", "dgdr_run", key), + "is managed by the operator and cannot be overridden", + )) + } + } + return allErrs +} diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go index 9ad45a24a0b9..981129efb985 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go @@ -18,46 +18,13 @@ package validation import ( - "encoding/json" "strings" "testing" - nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" ) -func v1beta2Ptr[T any](v T) *T { return &v } - -// v1beta2DefaultedSpec is a Request as it looks once the schema and admission have defaulted it. -func v1beta2DefaultedSpec() *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec { - metric := nvidiacomv1beta2.ObjectiveMetric("ThroughputPerGPU") - return &nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec{ - Model: &nvidiacomv1beta2.ModelSpec{Name: "meta-llama/Llama-3.1-8B-Instruct"}, - Backends: []nvidiacomv1beta2.Backend{nvidiacomv1beta2.BackendVLLM, nvidiacomv1beta2.BackendSGLang, nvidiacomv1beta2.BackendTRTLLM}, - Hardware: &nvidiacomv1beta2.HardwareSpec{GPU: &nvidiacomv1beta2.GPUHardwareSpec{SKU: v1beta2Ptr("h200_sxm"), Budget: v1beta2Ptr(int32(8))}}, - Workload: &nvidiacomv1beta2.WorkloadSpec{Static: &nvidiacomv1beta2.StaticWorkloadSpec{ - ISL: v1beta2Ptr(int32(4000)), OSL: v1beta2Ptr(int32(1000)), KVLoadRatio: nvidiacomv1beta2.KVLoadRatio{0, 1}, - }}, - Objective: &nvidiacomv1beta2.ObjectiveSpec{ - Mode: nvidiacomv1beta2.ObjectiveModeOptimize, - Metric: &metric, - SLA: &nvidiacomv1beta2.ObjectiveSLASpec{TTFTMs: v1beta2Ptr(2000.0), ITLMs: v1beta2Ptr(30.0)}, - }, - AISimulate: &nvidiacomv1beta2.AISimulateSpec{Image: "registry.example.com/aisimulate:1.6.0"}, - Recommendation: &nvidiacomv1beta2.RecommendationSpec{MaxCandidates: 1}, - } -} - -func v1beta2WithParameters(spec *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec, parameters string) *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec { - if spec.AISimulate == nil { - spec.AISimulate = &nvidiacomv1beta2.AISimulateSpec{Image: "registry.example.com/aisimulate:1.6.0"} - } - spec.AISimulate.Parameters = &runtime.RawExtension{Raw: []byte(parameters)} - return spec -} - func v1beta2Details(errs field.ErrorList) string { var out []string for _, err := range errs { @@ -80,66 +47,6 @@ func v1beta2RequireNoError(t *testing.T, errs field.ErrorList) { } } -func TestValidateV1beta2AcceptsADefaultedRequest(t *testing.T) { - v1beta2RequireNoError(t, validateDynamoGraphDeploymentRequestV1beta2(v1beta2DefaultedSpec())) -} - -// The webhook does not repeat what the generated schema and CEL enforce before conversion: -// native model presence, the model name length, backend uniqueness and the kvLoadRatio range -// order. The operatorenv table proves those errors end to end. -func TestValidateV1beta2LeavesSourceSchemaRulesToTheSchema(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.Model = nil - spec.Backends = []nvidiacomv1beta2.Backend{nvidiacomv1beta2.BackendVLLM, nvidiacomv1beta2.BackendVLLM} - spec.Workload.Static.KVLoadRatio = nvidiacomv1beta2.KVLoadRatio{0.9, 0.1} - v1beta2RequireNoError(t, validateDynamoGraphDeploymentRequestV1beta2(spec)) -} - -func TestValidateV1beta2ParametersAreOpaque(t *testing.T) { - for _, parameters := range []string{ - `{"workload":{"isl":0},"goal":{"target":"nope"}}`, - `[1]`, // the CRD types parameters as an object; admission does not interpret it - } { - v1beta2RequireNoError(t, validateDynamoGraphDeploymentRequestV1beta2( - v1beta2WithParameters(v1beta2DefaultedSpec(), parameters))) - } -} - -func TestValidateV1beta2AISimulateImage(t *testing.T) { - t.Run("no AISimulate section", func(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.AISimulate = nil - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestV1beta2(spec), "spec.aisimulate.image: an AISimulate image is required") - }) - t.Run("parameters without an image", func(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.AISimulate = &nvidiacomv1beta2.AISimulateSpec{} - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestV1beta2(spec), "spec.aisimulate.image") - }) - t.Run("custom tag needs a version", func(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.AISimulate.Image = "registry.example.com/aisimulate:custom" - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestV1beta2(spec), "spec.aisimulate.versionOverride: is required when the tag of spec.aisimulate.image is not a Dynamo release with a bundled AISimulate version") - }) - t.Run("custom tag with a version", func(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.AISimulate.Image = "registry.example.com/aisimulate:custom" - spec.AISimulate.VersionOverride = "0.13.1" - v1beta2RequireNoError(t, validateDynamoGraphDeploymentRequestV1beta2(spec)) - }) - t.Run("a Dynamo release without a bundled version needs one", func(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.AISimulate.Image = "registry.example.com/aisimulate:1.2.3" - spec.AISimulate.VersionOverride = "" - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestV1beta2(spec), "spec.aisimulate.versionOverride") - }) - t.Run("an override that is not a version", func(t *testing.T) { - spec := v1beta2DefaultedSpec() - spec.AISimulate.VersionOverride = "bad" - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestV1beta2(spec), "spec.aisimulate.versionOverride: must be a semantic version") - }) -} - func v1beta2Envelope(raw string) *apiextensionsv1.JSON { return &apiextensionsv1.JSON{Raw: []byte(raw)} } @@ -147,16 +54,8 @@ func v1beta2Envelope(raw string) *apiextensionsv1.JSON { func TestValidateEnvelopeV1beta2(t *testing.T) { path := field.NewPath("spec", "v1beta2") - good, err := json.Marshal(v1beta2DefaultedSpec()) - if err != nil { - t.Fatal(err) - } - v1beta2RequireNoError(t, validateDynamoGraphDeploymentRequestEnvelopeV1beta2(v1beta2Envelope(string(good)), path)) - - bad := v1beta2DefaultedSpec() - bad.AISimulate = nil - raw, _ := json.Marshal(bad) - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestEnvelopeV1beta2(v1beta2Envelope(string(raw)), path), "aisimulate") + v1beta2RequireNoError(t, validateDynamoGraphDeploymentRequestEnvelopeV1beta2( + v1beta2Envelope(`{"model":{"name":"meta-llama/Llama-3.1-8B-Instruct"}}`), path)) v1beta2RequireError(t, validateDynamoGraphDeploymentRequestEnvelopeV1beta2(v1beta2Envelope(`{"model": 3}`), path), "spec.v1beta2: must be a valid v1beta2 spec") } diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go index f9f0f206108d..d528278f60ab 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go @@ -61,6 +61,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }` discoveredH200 := gpu.GPUInfo{System: "h200_sxm", GPUsPerNode: 8, NodesWithGPUs: 1} + discoveredH100 := gpu.GPUInfo{System: "h100_sxm", GPUsPerNode: 8, NodesWithGPUs: 1} tests := []struct { name string @@ -116,8 +117,9 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { gpuDiscovery: true, }, { - name: "v1beta2 create bypasses legacy v1beta1 validation", - request: beta2DGDRForAdmission(setBeta2AdmissionHardware), + name: "v1beta2 create bypasses legacy v1beta1 validation", + request: beta2DGDRForAdmission(setBeta2AdmissionHardware), + discovered: discoveredH100, }, { name: "v1beta2 update bypasses legacy v1beta1 validation", @@ -126,6 +128,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { setBeta2AdmissionHardware(request) request.Labels = map[string]string{"updated": "true"} }), + discovered: discoveredH100, }, { name: "v1beta1 empty native representation is rejected by envelope validation", @@ -559,7 +562,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 explicit hardware is kept", + name: "v1beta2 explicit hardware is kept after admission verifies the SKU", request: v1beta2DGDRForAdmission(func(spec map[string]any) { spec["hardware"] = map[string]any{"gpu": map[string]any{"sku": "h100_sxm", "budget": int64(4)}} }, nil), @@ -575,23 +578,37 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 parameters are stored untouched and never decide hardware defaulting", + name: "v1beta2 explicit hardware is rejected when the SKU cannot be verified", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - setV1beta2SearchSpace(spec, map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(4)}) + spec["hardware"] = map[string]any{"gpu": map[string]any{"sku": "h100_sxm", "budget": int64(4)}} + }, nil), + gpuDiscovery: true, + discoveryErr: errors.New("no matching GPUs found"), + wantWebhook: []string{ + `spec.hardware.gpu.sku: Invalid value: "h100_sxm": could not be verified against the cluster's GPUs (no matching GPUs found)`, + }, + }, + { + name: "v1beta2 input overrides are stored untouched and never decide hardware defaulting", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + setV1beta2InputOverrides(spec, map[string]any{ + "engine": map[string]any{"hardware": "h100_sxm"}, + "optimization": map[string]any{"constraints": map[string]any{"max_candidate_gpus": int64(4)}}, + }) }, nil), gpuDiscovery: true, discovered: discoveredH200, check: func(t *testing.T, result *unstructured.Unstructured) { expectV1beta2GPUValues(t, result, "h200_sxm", 8) - if got := v1beta2String(t, result, "spec", "aisimulate", "parameters", "search_space", "hardware_sku"); got != "h100_sxm" { - t.Fatalf("parameters.search_space.hardware_sku = %q, want h100_sxm", got) + if got := v1beta2String(t, result, "spec", "aisimulate", "inputOverrides", "engine", "hardware"); got != "h100_sxm" { + t.Fatalf("inputOverrides.engine.hardware = %q, want h100_sxm", got) } }, }, { - name: "v1beta2 parameters null does not suppress hardware discovery", + name: "v1beta2 input overrides null does not suppress hardware discovery", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - setV1beta2SearchSpace(spec, map[string]any{"hardware_sku": nil}) + setV1beta2InputOverrides(spec, map[string]any{"engine": map[string]any{"hardware": nil}}) }, nil), gpuDiscovery: true, discovered: discoveredH200, @@ -604,10 +621,16 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { { name: "v1beta2 metadata-only update does not read the cluster again", oldRequest: v1beta2DGDRForAdmission(func(spec map[string]any) { - setV1beta2SearchSpace(spec, map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(8)}) + setV1beta2InputOverrides(spec, map[string]any{ + "engine": map[string]any{"hardware": "h100_sxm"}, + "optimization": map[string]any{"constraints": map[string]any{"max_candidate_gpus": int64(8)}}, + }) }, nil), request: v1beta2DGDRForAdmission(func(spec map[string]any) { - setV1beta2SearchSpace(spec, map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(8)}) + setV1beta2InputOverrides(spec, map[string]any{ + "engine": map[string]any{"hardware": "h100_sxm"}, + "optimization": map[string]any{"constraints": map[string]any{"max_candidate_gpus": int64(8)}}, + }) }, map[string]any{"updated": "true"}), gpuDiscovery: true, discovered: discoveredH200, @@ -669,12 +692,14 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { gpuDiscovery: true, discovered: discoveredH200, check: func(t *testing.T, result *unstructured.Unstructured) { - for key, want := range map[string]float64{"isl": 1024, "osl": 128, "concurrency": 10} { + for key, want := range map[string]float64{"inputTokens": 1024, "outputTokens": 128} { if got := v1beta2Number(t, result, "spec", "workload", "static", key); got != want { t.Fatalf("workload.static.%s = %v, want %v", key, got, want) } } - v1beta2Absent(t, result, "spec", "workload", "static", "kvLoadRatio") + if got := v1beta2Number(t, result, "spec", "workload", "static", "load", "concurrency"); got != 10 { + t.Fatalf("workload.static.load.concurrency = %v, want 10", got) + } }, }, { @@ -689,16 +714,15 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 empty workload static stays empty beside native parameters", + name: "v1beta2 empty workload static stays empty beside public input overrides", request: v1beta2DGDRForAdmission(func(spec map[string]any) { spec["workload"] = map[string]any{"static": map[string]any{}} - spec["aisimulate"] = map[string]any{"parameters": mustJSONObject(t, `{"workload":{"isl":512,"osl":128,"concurrency":8,"request_count":100}}`)} + spec["aisimulate"] = map[string]any{"inputOverrides": mustJSONObject(t, `{"traffic":{"source":{"type":"synthetic","input_tokens":512,"output_tokens":128},"load":{"type":"concurrency","concurrency":8},"stop":{"requests":100}}}`)} }, nil), gpuDiscovery: true, discovered: discoveredH200, check: func(t *testing.T, result *unstructured.Unstructured) { v1beta2Empty(t, result, "spec", "workload", "static") - v1beta2Absent(t, result, "spec", "objective", "sla") expectV1beta2AdmissionDefaults(t, result) }, }, @@ -711,84 +735,103 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { discovered: discoveredH200, check: func(t *testing.T, result *unstructured.Unstructured) { v1beta2Empty(t, result, "spec", "objective", "sla") - if got := v1beta2Number(t, result, "spec", "workload", "static", "isl"); got != 1024 { - t.Fatalf("workload.static.isl = %v, want the schema default 1024", got) + if got := v1beta2Number(t, result, "spec", "workload", "static", "inputTokens"); got != 1024 { + t.Fatalf("workload.static.inputTokens = %v, want the admission default 1024", got) } }, }, { - name: "v1beta2 native parameters win without rewriting the typed spec", + name: "v1beta2 public input overrides win without rewriting the typed spec", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"parameters": mustJSONObject(t, `{"workload":{"isl":256}}`)} + spec["aisimulate"] = map[string]any{"inputOverrides": mustJSONObject(t, `{"traffic":{"source":{"input_tokens":256}}}`)} }, nil), gpuDiscovery: true, discovered: discoveredH200, check: func(t *testing.T, result *unstructured.Unstructured) { - if got := v1beta2Number(t, result, "spec", "workload", "static", "isl"); got != 1024 { - t.Fatalf("typed workload.static.isl = %v, want 1024", got) + if got := v1beta2Number(t, result, "spec", "workload", "static", "inputTokens"); got != 1024 { + t.Fatalf("typed workload.static.inputTokens = %v, want 1024", got) } - if got := v1beta2Number(t, result, "spec", "aisimulate", "parameters", "workload", "isl"); got != 256 { - t.Fatalf("parameters.workload.isl = %v, want 256", got) + if got := v1beta2Number(t, result, "spec", "aisimulate", "inputOverrides", "traffic", "source", "input_tokens"); got != 256 { + t.Fatalf("inputOverrides.traffic.source.input_tokens = %v, want 256", got) } }, }, { - name: "v1beta2 stored invalid spec is rejected again on a metadata-only update", - oldRequest: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"image": "registry.example.com/aisimulate:custom"} + name: "v1beta2 public input overrides may configure non-reserved dgdr run fields", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": map[string]any{ + "generator": "aic", + "snapshot_interval_seconds": int64(30), + }}) }, nil), + gpuDiscovery: true, + discovered: discoveredH200, + check: func(t *testing.T, result *unstructured.Unstructured) { + if got := v1beta2String(t, result, "spec", "aisimulate", "inputOverrides", "dgdr_run", "generator"); got != "aic" { + t.Fatalf("dgdr_run.generator = %q, want aic", got) + } + }, + }, + { + name: "v1beta2 operator-owned dgdr run input cannot be overridden", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"image": "registry.example.com/aisimulate:custom"} - }, map[string]any{"updated": "true"}), - seedWithoutWebhook: true, - gpuDiscovery: true, - discovered: discoveredH200, + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": map[string]any{ + "derive_runtime_images": false, + "max_candidates": int64(3), + "name": "foreign-run", + "namespace": "other", + "num_gpus_per_node": int64(4), + "runtime_image": "example.invalid/runtime:latest", + "runtime_version_override": "1.2.3", + "snapshot_dir": "/tmp/snapshots", + }}) + }, nil), + gpuDiscovery: true, + discovered: discoveredH200, wantWebhook: []string{ - "spec.aisimulate.versionOverride: Required value: is required when the tag of spec.aisimulate.image is not a Dynamo release with a bundled AISimulate version", + "spec.aisimulate.inputOverrides.dgdr_run.derive_runtime_images: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.max_candidates: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.name: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.namespace: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.num_gpus_per_node: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.runtime_image: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.runtime_version_override: Forbidden: is managed by the operator and cannot be overridden", + "spec.aisimulate.inputOverrides.dgdr_run.snapshot_dir: Forbidden: is managed by the operator and cannot be overridden", }, }, { - name: "v1beta2 model-only request stores the configured AISimulate image and its override", - request: v1beta2DGDRForAdmission(nil, nil), + name: "v1beta2 dgdr run input cannot be deleted", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": nil}) + }, nil), gpuDiscovery: true, discovered: discoveredH200, - check: func(t *testing.T, result *unstructured.Unstructured) { - expectV1beta2AdmissionDefaults(t, result) + wantWebhook: []string{ + "spec.aisimulate.inputOverrides.dgdr_run: Forbidden: must be an object because the operator owns required execution fields within it", }, }, { - name: "v1beta2 caller image does not receive the operator's version override", + name: "v1beta2 dgdr run input cannot be replaced by a scalar", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"image": "registry.example.com/aisimulate:1.6.0"} + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": "wrong"}) }, nil), gpuDiscovery: true, discovered: discoveredH200, - check: func(t *testing.T, result *unstructured.Unstructured) { - if got := v1beta2String(t, result, "spec", "aisimulate", "image"); got != "registry.example.com/aisimulate:1.6.0" { - t.Fatalf("aisimulate.image = %q, want the caller's image", got) - } - if _, found, _ := unstructured.NestedString(result.Object, "spec", "aisimulate", "versionOverride"); found { - t.Fatalf("aisimulate.versionOverride was set next to a caller's image") - } + wantWebhook: []string{ + "spec.aisimulate.inputOverrides.dgdr_run: Forbidden: must be an object because the operator owns required execution fields within it", }, }, { - name: "v1beta2 caller override wins next to the configured image", + name: "v1beta2 dgdr run input cannot be replaced by a list", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "2.0.0"} + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": []any{"wrong"}}) }, nil), gpuDiscovery: true, discovered: discoveredH200, - check: func(t *testing.T, result *unstructured.Unstructured) { - if got := v1beta2String(t, result, "spec", "aisimulate", "image"); got != admissionAISimulateImage { - t.Fatalf("aisimulate.image = %q, want the configured image", got) - } - if got := v1beta2String(t, result, "spec", "aisimulate", "versionOverride"); got != "2.0.0" { - t.Fatalf("aisimulate.versionOverride = %q, want the caller's 2.0.0", got) - } + wantWebhook: []string{ + "spec.aisimulate.inputOverrides.dgdr_run: Forbidden: must be an object because the operator owns required execution fields within it", }, }, - { name: "v1beta2 hardware omitted is discovered and the budget capped", request: v1beta2DGDRForAdmission(nil, nil), @@ -832,12 +875,12 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 a SKU and a budget are kept without reading the cluster", + name: "v1beta2 a SKU and a budget are kept after SKU verification", request: v1beta2DGDRForAdmission(func(spec map[string]any) { spec["hardware"] = map[string]any{"gpu": map[string]any{"sku": "h100_sxm", "budget": int64(4)}} }, nil), gpuDiscovery: true, - discoveryErr: errors.New("the cluster must not be read"), + discovered: gpu.GPUInfo{System: "h100_sxm", GPUsPerNode: 8, NodesWithGPUs: 1}, check: func(t *testing.T, result *unstructured.Unstructured) { expectV1beta2GPUValues(t, result, "h100_sxm", 4) }, @@ -905,8 +948,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { gpuDiscovery: true, discovered: gpu.GPUInfo{System: "h100_sxm"}, wantWebhook: []string{ - `spec.hardware.gpu.budget: Required value: no GPUs of SKU "h100_sxm" were found in the cluster; ` + - "set spec.hardware.gpu.budget", + `spec.hardware.gpu.sku: Invalid value: "h100_sxm": no GPUs of this SKU were found in the cluster`, }, }, @@ -924,27 +966,31 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 pareto objective receives no scalar metric", + name: "v1beta2 pareto objective is reserved until Candidate publication supports it", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + spec["objective"] = map[string]any{"mode": "Pareto", "metrics": []any{"ThroughputPerGPU", "ThroughputPerUser"}} + }, nil), + gpuDiscovery: true, + discovered: discoveredH200, + wantSchemaErr: `spec.objective.mode: Unsupported value: "Pareto": supported values: "Optimize"`, + }, + { + name: "v1beta2 optimize rejects the reserved Pareto metrics field", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["objective"] = map[string]any{"mode": "Pareto", "metrics": []any{"TTFT", "Throughput"}} + spec["objective"] = map[string]any{"mode": "Optimize", "metrics": []any{"ThroughputPerGPU", "ThroughputPerUser"}} }, nil), gpuDiscovery: true, discovered: discoveredH200, - check: func(t *testing.T, result *unstructured.Unstructured) { - v1beta2Absent(t, result, "spec", "objective", "metric") - }, + wantCELErr: `spec.objective: Invalid value: metrics is reserved for the future Pareto implementation`, }, { name: "v1beta2 explicit values are all kept", request: v1beta2DGDRForAdmission(func(spec map[string]any) { spec["backends"] = []any{"vllm"} spec["hardware"] = map[string]any{"gpu": map[string]any{"sku": "h100_sxm", "budget": int64(4)}} - spec["workload"] = map[string]any{"static": map[string]any{"isl": int64(10)}} + spec["workload"] = map[string]any{"static": map[string]any{"inputTokens": int64(10)}} spec["objective"] = map[string]any{"mode": "Optimize", "metric": "TTFT"} - spec["aisimulate"] = map[string]any{ - "image": "registry.example.com/aisimulate:custom", - "versionOverride": "1.4.0", - } + spec["aisimulate"] = map[string]any{"inputOverrides": map[string]any{"custom": "kept"}} spec["recommendation"] = map[string]any{"maxCandidates": int64(3)} }, nil), gpuDiscovery: true, @@ -977,46 +1023,47 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { wantSchemaErr: `spec.model.name: Invalid value: "": spec.model.name in body should be at least 1 chars long`, }, { - name: "v1beta2 kvLoadRatio range needs min below max", + name: "v1beta2 KV capacity fraction range needs min below max", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["workload"] = map[string]any{"static": map[string]any{"kvLoadRatio": []any{0.9, 0.1}}} + spec["workload"] = map[string]any{"static": map[string]any{"load": map[string]any{ + "kvCapacityFraction": map[string]any{"range": map[string]any{"min": 0.9, "max": 0.1}}, + }}} }, nil), gpuDiscovery: true, discovered: discoveredH200, - wantCELErr: `spec.workload.static.kvLoadRatio: Invalid value: a range needs min < max`, + wantCELErr: `spec.workload.static.load.kvCapacityFraction.range: Invalid value: min must be less than max`, }, { - name: "v1beta2 versionOverride latest is rejected by the schema", + name: "v1beta2 KV capacity fraction must be positive", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "latest"} + spec["workload"] = map[string]any{"static": map[string]any{"load": map[string]any{ + "kvCapacityFraction": map[string]any{"value": 0.0}, + }}} }, nil), gpuDiscovery: true, discovered: discoveredH200, - wantSchemaErr: `spec.aisimulate.versionOverride: Invalid value: "latest": spec.aisimulate.versionOverride in body should match '^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$'`, + wantSchemaErr: `spec.workload.static.load.kvCapacityFraction.value: Invalid value: 0: spec.workload.static.load.kvCapacityFraction.value in body should be greater than 0`, }, { - name: "v1beta2 versionOverride with a v prefix is rejected by the schema", + name: "v1beta2 load requires exactly one variant", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "v0.13.0"} + spec["workload"] = map[string]any{"static": map[string]any{"load": map[string]any{ + "concurrency": int64(10), "requestsPerSecond": 10.0, + }}} }, nil), - gpuDiscovery: true, - discovered: discoveredH200, - wantSchemaErr: `spec.aisimulate.versionOverride: Invalid value: "v0.13.0": spec.aisimulate.versionOverride in body should match '^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$'`, + gpuDiscovery: true, + discovered: discoveredH200, + wantCELErr: `spec.workload.static.load: Invalid value: exactly one of requestsPerSecond, concurrency, or kvCapacityFraction must be specified`, }, { - name: "v1beta2 versionOverride 0.13.0 is accepted", + name: "v1beta2 empty load is not a second default opt-out", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "0.13.0"} + spec["workload"] = map[string]any{"static": map[string]any{"load": map[string]any{}}} }, nil), gpuDiscovery: true, discovered: discoveredH200, - check: func(t *testing.T, result *unstructured.Unstructured) { - if got := v1beta2String(t, result, "spec", "aisimulate", "versionOverride"); got != "0.13.0" { - t.Fatalf("versionOverride = %q, want 0.13.0", got) - } - }, + wantCELErr: `spec.workload.static.load: Invalid value: exactly one of requestsPerSecond, concurrency, or kvCapacityFraction must be specified`, }, - // v1beta2 UPDATE: defaults stay idempotent, hardware is never rediscovered, and any // spec change is fully validated. { @@ -1047,8 +1094,8 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { t.Fatalf("generation = %d, want 1", result.GetGeneration()) } expectV1beta2GPUValues(t, result, "h200_sxm", 8) - if got := v1beta2String(t, result, "spec", "aisimulate", "parameters", "search_space", "hardware_sku"); got != "h100_sxm" { - t.Fatalf("parameters.search_space.hardware_sku = %q, want h100_sxm", got) + if got := v1beta2String(t, result, "spec", "aisimulate", "inputOverrides", "engine", "hardware"); got != "h100_sxm" { + t.Fatalf("inputOverrides.engine.hardware = %q, want h100_sxm", got) } }, }, @@ -1122,6 +1169,40 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { } } +func TestDynamoGraphDeploymentRunSpecIsImmutable(t *testing.T) { + tests := []struct { + name string + oldRun *nvidiacomv1beta2.DynamoGraphDeploymentRun + run *nvidiacomv1beta2.DynamoGraphDeploymentRun + wantCELErr string + }{ + { + name: "metadata-only update keeps the immutable spec", + oldRun: v1beta2RunForAdmission(v1beta2AdmissionModel, nil), + run: v1beta2RunForAdmission(v1beta2AdmissionModel, map[string]string{"updated": "true"}), + }, + { + name: "spec update is rejected by the API server", + oldRun: v1beta2RunForAdmission(v1beta2AdmissionModel, nil), + run: v1beta2RunForAdmission(alternateAdmissionModel, nil), + wantCELErr: `: Invalid value: spec is immutable`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := runAdmissionTest(t, admissionTestCase{ + object: tt.run, + oldObject: tt.oldRun, + wantCELError: tt.wantCELErr, + }) + if tt.wantCELErr == "" && actual.GetGeneration() != 1 { + t.Fatalf("generation = %d, want 1 for a metadata-only update", actual.GetGeneration()) + } + }) + } +} + func TestDynamoGraphDeploymentRequestStatusRepresentationMatchesSpec(t *testing.T) { tests := []struct { name string @@ -1172,6 +1253,7 @@ func TestDynamoGraphDeploymentRequestStatusRepresentationMatchesSpec(t *testing. t.Log("Create a request through the production API and conversion path") env := admissionEnv.ForTest(t) admissionGate.set(features.Gates{GPUDiscovery: true}) + setAdmissionH100Topology() warnings := &warningRecorder{} createClient := newAdmissionResourceClient(t, env, test.createObject, "", warnings) object, _ := admissionObject(t, test.createObject, env.Namespace(), nil) @@ -1203,6 +1285,7 @@ func TestDynamoGraphDeploymentRequestV1Beta2EmptyStatusSurvivesV1Alpha1(t *testi t.Log("Create a native v1beta2 request with empty status") env := admissionEnv.ForTest(t) admissionGate.set(features.Gates{GPUDiscovery: true}) + setAdmissionH100Topology() warnings := &warningRecorder{} beta2Fixture := beta2DGDRForAdmission(func(request *nvidiacomv1beta2.DynamoGraphDeploymentRequest) { request.Name = "empty-status-alpha-bridge" @@ -1320,16 +1403,26 @@ func beta2DGDRForAdmission( return request } +func v1beta2RunForAdmission(model string, labels map[string]string) *nvidiacomv1beta2.DynamoGraphDeploymentRun { + return &nvidiacomv1beta2.DynamoGraphDeploymentRun{ + APIVersion: nvidiacomv1beta2.GroupVersion.String(), + Kind: "DynamoGraphDeploymentRun", + Name: "test-v1beta2-dgdr-run", Namespace: "default", Labels: labels, + Spec: nvidiacomv1beta2.DynamoGraphDeploymentRunSpec{NativeSearchInput: nvidiacomv1beta2.NativeSearchInput{ + Model: &nvidiacomv1beta2.ModelSpec{Name: model}, + }}, + } +} + const v1beta2AdmissionModel = "Qwen/Qwen3-0.6B" -// v1beta2NativeHardwareDGDR supplies hardware only through native AISimulate parameters. +// v1beta2NativeHardwareDGDR supplies hardware only through public AISimulate input overrides. func v1beta2NativeHardwareDGDR(labels map[string]any) *unstructured.Unstructured { return v1beta2DGDRForAdmission(func(spec map[string]any) { spec["aisimulate"] = map[string]any{ - "image": admissionAISimulateImage, - "versionOverride": admissionAISimulateVersionOverride, - "parameters": map[string]any{ - "search_space": map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(8)}, + "inputOverrides": map[string]any{ + "engine": map[string]any{"hardware": "h100_sxm"}, + "optimization": map[string]any{"constraints": map[string]any{"max_candidate_gpus": int64(8)}}, }, } }, labels) @@ -1356,7 +1449,7 @@ func v1beta2DGDRForAdmission(mutate func(spec map[string]any), labels map[string }} } -// mustJSONObject decodes a native AISimulate parameters document. +// mustJSONObject decodes a native AISimulate input override document. func mustJSONObject(t *testing.T, document string) map[string]any { t.Helper() var value map[string]any @@ -1405,13 +1498,6 @@ func v1beta2String(t *testing.T, obj *unstructured.Unstructured, path ...string) return text } -func v1beta2Absent(t *testing.T, obj *unstructured.Unstructured, path ...string) { - t.Helper() - if value, found := v1beta2Field(t, obj, path...); found { - t.Fatalf("%v = %#v, want it absent", path, value) - } -} - func v1beta2Empty(t *testing.T, obj *unstructured.Unstructured, path ...string) { t.Helper() value, found := v1beta2Field(t, obj, path...) @@ -1423,16 +1509,23 @@ func v1beta2Empty(t *testing.T, obj *unstructured.Unstructured, path ...string) // expectV1beta2StructuralDefaults checks the defaults admission supplies to a model-only Request. func expectV1beta2StructuralDefaults(t *testing.T, obj *unstructured.Unstructured) { t.Helper() - for key, want := range map[string]float64{"isl": 1024, "osl": 128, "concurrency": 10} { + for key, want := range map[string]float64{"inputTokens": 1024, "outputTokens": 128} { if got := v1beta2Number(t, obj, "spec", "workload", "static", key); got != want { t.Fatalf("workload.static.%s = %v, want %v", key, got, want) } } - v1beta2Absent(t, obj, "spec", "workload", "static", "kvLoadRatio") - v1beta2Absent(t, obj, "spec", "objective", "sla") + if got := v1beta2Number(t, obj, "spec", "workload", "static", "load", "concurrency"); got != 10 { + t.Fatalf("workload.static.load.concurrency = %v, want 10", got) + } if got := v1beta2String(t, obj, "spec", "objective", "mode"); got != "Optimize" { t.Fatalf("objective.mode = %q, want Optimize", got) } + if got := v1beta2Number(t, obj, "spec", "objective", "sla", "ttftMs"); got != 2000 { + t.Fatalf("objective.sla.ttftMs = %v, want 2000", got) + } + if got := v1beta2Number(t, obj, "spec", "objective", "sla", "itlMs"); got != 30 { + t.Fatalf("objective.sla.itlMs = %v, want 30", got) + } if got, _ := v1beta2Field(t, obj, "spec", "backends"); !reflect.DeepEqual(got, []any{"vllm", "sglang", "trtllm"}) { t.Fatalf("backends = %#v, want vllm, sglang and trtllm", got) } @@ -1452,7 +1545,9 @@ func expectV1beta2ExplicitValues(t *testing.T, obj *unstructured.Unstructured) { value float64 }{ {[]string{"spec", "hardware", "gpu", "budget"}, 4}, - {[]string{"spec", "workload", "static", "isl"}, 10}, + {[]string{"spec", "workload", "static", "inputTokens"}, 10}, + {[]string{"spec", "workload", "static", "outputTokens"}, 128}, + {[]string{"spec", "workload", "static", "load", "concurrency"}, 10}, {[]string{"spec", "recommendation", "maxCandidates"}, 3}, } { if got := v1beta2Number(t, obj, want.path...); got != want.value { @@ -1466,29 +1561,20 @@ func expectV1beta2ExplicitValues(t *testing.T, obj *unstructured.Unstructured) { {[]string{"spec", "hardware", "gpu", "sku"}, "h100_sxm"}, {[]string{"spec", "objective", "mode"}, "Optimize"}, {[]string{"spec", "objective", "metric"}, "TTFT"}, - {[]string{"spec", "aisimulate", "image"}, "registry.example.com/aisimulate:custom"}, - {[]string{"spec", "aisimulate", "versionOverride"}, "1.4.0"}, + {[]string{"spec", "aisimulate", "inputOverrides", "custom"}, "kept"}, } { if got := v1beta2String(t, obj, want.path...); got != want.value { t.Fatalf("%v = %q, want %q", want.path, got, want.value) } } - // A static workload that names only isl is not filled in. - v1beta2Absent(t, obj, "spec", "workload", "static", "osl") - v1beta2Absent(t, obj, "spec", "workload", "static", "concurrency") + // A nonempty static workload receives every omitted member of the default. } // expectV1beta2AdmissionDefaults checks what admission supplies from the environment. func expectV1beta2AdmissionDefaults(t *testing.T, obj *unstructured.Unstructured) { t.Helper() - if got := v1beta2String(t, obj, "spec", "objective", "metric"); got != "Throughput" { - t.Fatalf("objective.metric = %q, want Throughput", got) - } - if got := v1beta2String(t, obj, "spec", "aisimulate", "image"); got != admissionAISimulateImage { - t.Fatalf("aisimulate.image = %q, want %q", got, admissionAISimulateImage) - } - if got := v1beta2String(t, obj, "spec", "aisimulate", "versionOverride"); got != admissionAISimulateVersionOverride { - t.Fatalf("aisimulate.versionOverride = %q, want %q", got, admissionAISimulateVersionOverride) + if got := v1beta2String(t, obj, "spec", "objective", "metric"); got != "ThroughputPerGPU" { + t.Fatalf("objective.metric = %q, want ThroughputPerGPU", got) } if got := v1beta2String(t, obj, "spec", "hardware", "gpu", "sku"); got != "h200_sxm" { t.Fatalf("hardware.gpu.sku = %q, want h200_sxm", got) @@ -1498,15 +1584,14 @@ func expectV1beta2AdmissionDefaults(t *testing.T, obj *unstructured.Unstructured } } -// setV1beta2SearchSpace sets spec.aisimulate.parameters.search_space to searchSpace, replacing -// any parameters the fixture carried. -func setV1beta2SearchSpace(spec map[string]any, searchSpace map[string]any) { +// setV1beta2InputOverrides replaces any input overrides the fixture carried. +func setV1beta2InputOverrides(spec map[string]any, overrides map[string]any) { aisimulate, _ := spec["aisimulate"].(map[string]any) if aisimulate == nil { aisimulate = map[string]any{} spec["aisimulate"] = aisimulate } - aisimulate["parameters"] = map[string]any{"search_space": searchSpace} + aisimulate["inputOverrides"] = overrides } // setBeta2AdmissionHardware names the hardware, so fixtures do not depend on GPU discovery. diff --git a/deploy/operator/internal/webhook/validation/planner_runtime_features.go b/deploy/operator/internal/webhook/validation/planner_runtime_features.go index c61ff68ec318..1e772c501f72 100644 --- a/deploy/operator/internal/webhook/validation/planner_runtime_features.go +++ b/deploy/operator/internal/webhook/validation/planner_runtime_features.go @@ -13,7 +13,7 @@ import ( "github.com/ai-dynamo/dynamo/deploy/operator/internal/consts" "github.com/ai-dynamo/dynamo/deploy/operator/internal/dynamo" runtimefeatures "github.com/ai-dynamo/dynamo/deploy/operator/internal/features/runtime" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" "k8s.io/apimachinery/pkg/util/validation/field" ) @@ -117,7 +117,7 @@ func rolePodTemplatesPlannerRuntimeErrorDetail(planners []plannerRuntimeContract minimum := runtimefeatures.PlannerDGDComponentStatus.MinRuntimeVersion.String() for _, planner := range planners { - version, err := versionoverride.Resolve(planner.image, planner.runtimeVersionOverride) + version, err := runtimeversion.Resolve(planner.image, planner.runtimeVersionOverride) if err != nil { return fmt.Sprintf( "role-specific PodTemplates require every planner component to use Dynamo runtime %s or later; planner component %q has no resolvable runtime version: %v", diff --git a/deploy/operator/internal/webhook/validation/shared_helpers.go b/deploy/operator/internal/webhook/validation/shared_helpers.go index 19e302c7bfac..053c8e1f0041 100644 --- a/deploy/operator/internal/webhook/validation/shared_helpers.go +++ b/deploy/operator/internal/webhook/validation/shared_helpers.go @@ -30,7 +30,7 @@ import ( "github.com/ai-dynamo/dynamo/deploy/operator/internal/dynamo/epp" "github.com/ai-dynamo/dynamo/deploy/operator/internal/features" runtimefeatures "github.com/ai-dynamo/dynamo/deploy/operator/internal/features/runtime" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/versionoverride" + "github.com/ai-dynamo/dynamo/deploy/operator/internal/runtimeversion" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" @@ -153,7 +153,7 @@ func runtimeVersionOverrideRequired(image, override string) bool { if override != "" { return false } - _, err := versionoverride.ParseImageVersion(image) + _, err := runtimeversion.ParseImageVersion(image) return err != nil } @@ -196,7 +196,7 @@ func eppRuntimeCompatibilityError(contract eppRuntimeContract, eppConfigPath *fi return nil } - version, err := versionoverride.Resolve(contract.image, contract.runtimeVersionOverride) + version, err := runtimeversion.Resolve(contract.image, contract.runtimeVersionOverride) if err != nil { return nil } diff --git a/deploy/operator/internal/webhook/validation/suite_envtest_test.go b/deploy/operator/internal/webhook/validation/suite_envtest_test.go index 7b0425387176..8cd00d703e01 100644 --- a/deploy/operator/internal/webhook/validation/suite_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/suite_envtest_test.go @@ -45,10 +45,6 @@ const ( frontendImage150 = "registry.example/dynamo-frontend:1.5.0" legacyEPPImage140 = "registry.example/epp-image:1.4.0" legacySeedUsername = "operatorenv-legacy-seeder" - admissionAISimulateImage = "registry.example.com/aisimulate:nightly" - // admissionAISimulateVersionOverride is the AISimulate version bundled in the configured - // default image, whose tag is not a Dynamo release. - admissionAISimulateVersionOverride = "0.13.0" ) func groveProviderOverride(target, value string) *nvidiacomv1beta1.ProviderOverride { @@ -101,9 +97,7 @@ func setupAdmissionWebhooks(mgr ctrl.Manager, opts operatorenv.WebhookSetupOptio OperatorPrincipal: opts.OperatorPrincipal, Gate: admissionGate, DGDRV1beta2: webhookdefaulting.DGDRV1beta2Options{ - AISimulateImage: admissionAISimulateImage, - AISimulateVersionOverride: admissionAISimulateVersionOverride, - Hardware: admissionHardware, + Hardware: admissionHardware, }, }) } @@ -129,6 +123,12 @@ func (f *fakeHardwareDiscoverer) Discover(context.Context, nvidiacomv1beta1.GPUS return &found, f.err } +func setAdmissionH100Topology() { + admissionHardware.set(gpu.GPUInfo{ + System: "h100_sxm", GPUsPerNode: 8, NodesWithGPUs: 1, + }, nil) +} + type mutableFeatureGate struct { mu sync.RWMutex gates features.Gate @@ -304,6 +304,8 @@ func admissionResource(t *testing.T, kind string) string { return "dynamographdeployments" case "DynamoGraphDeploymentRequest": return "dynamographdeploymentrequests" + case "DynamoGraphDeploymentRun": + return "dynamographdeploymentruns" default: t.Fatalf("unsupported admission kind %q", kind) return "" @@ -330,6 +332,8 @@ func admissionGVK(t *testing.T, object runtime.Object) schema.GroupVersionKind { return nvidiacomv1beta1.DynamoGraphDeploymentRequestGVK case *nvidiacomv1beta2.DynamoGraphDeploymentRequest: return nvidiacomv1beta2.DynamoGraphDeploymentRequestGVK + case *nvidiacomv1beta2.DynamoGraphDeploymentRun: + return nvidiacomv1beta2.DynamoGraphDeploymentRunGVK default: t.Fatalf("unsupported admission object type %T", object) return schema.GroupVersionKind{} diff --git a/docs/fern/pages/reference/kubernetes-api/additional-resources/api-reference-k8s.md b/docs/fern/pages/reference/kubernetes-api/additional-resources/api-reference-k8s.md index efe88648072f..56becfae40c3 100644 --- a/docs/fern/pages/reference/kubernetes-api/additional-resources/api-reference-k8s.md +++ b/docs/fern/pages/reference/kubernetes-api/additional-resources/api-reference-k8s.md @@ -3226,11 +3226,28 @@ Package v1beta2 contains API Schema definitions for the nvidia.com v1beta2 API g +#### AISimulateRunStatus + + + +AISimulateRunStatus reports the exact public recommendation input used for this execution. + + + +_Appears in:_ +- [DynamoGraphDeploymentRunStatus](#dynamographdeploymentrunstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `input` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#rawextension-runtime-pkg)_ | Input is the fully lowered AISimulate input after typed fields, derived defaults and
spec.aisimulate.inputOverrides have been merged. Field names and nesting use the
upstream AISimulate JSON contract. | | Type: object
Optional: \{\}
| + + #### AISimulateSpec -AISimulateSpec selects the AISimulate execution artifact and supplies native input. +AISimulateSpec supplies public recommendation input to the AISimulate version in the operator's configured +planner image. @@ -3242,9 +3259,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `image` _string_ | Image selects a Dynamo-compatible AISimulate runner. When omitted, admission
persists the operator's configured AISimulate image, or the Dynamo planner image of
the operator's release, together with the AISimulate version it bundles. | | MinLength: 1
Optional: \{\}
| -| `versionOverride` _string_ | nightly or custom image), and it wins over the mapping. | | MinLength: 1
Pattern: `^(0\|[1-9][0-9]\{0,3\})\.(0\|[1-9][0-9]\{0,3\})\.(0\|[1-9][0-9]\{0,3\})$`
Optional: \{\}
| -| `parameters` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#rawextension-runtime-pkg)_ | Parameters contains native AISimulate input. Field names and nesting use
the upstream AISimulate JSON contract, including snake_case names. | | Type: object
Optional: \{\}
| +| `inputOverrides` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#rawextension-runtime-pkg)_ | InputOverrides is an RFC 7386 JSON Merge Patch over the input lowered from
the typed fields. It uses the upstream AISimulate JSON contract, including
snake_case names. Values here take precedence, and null deletes a lowered
field. The dgdr_run member must remain an object, and its operator-owned
transport, identity, and runtime fields name, max_candidates, snapshot_dir,
runtime_image, runtime_version_override, derive_runtime_images,
num_gpus_per_node, and namespace are operator-owned. Request admission
rejects them, and the Run controller enforces its values while lowering the
immutable snapshot. The operator runs the AISimulate version from its
configured planner image; Requests and Runs cannot select a different image
or version. | | Type: object
Optional: \{\}
| #### Backend @@ -3438,9 +3453,9 @@ _Appears in:_ | `model` _[ModelSpec](#modelspec)_ | Model identifies the model whose deployment configurations are evaluated. | | Optional: \{\}
| | `backends` _[Backend](#backend) array_ | Backends lists one or more inference backends searched by AISimulate and used by generated candidates. | | Enum: [vllm sglang trtllm]
MinItems: 1
Optional: \{\}
| | `hardware` _[HardwareSpec](#v1beta2-hardwarespec)_ | Hardware bounds the accelerator configurations evaluated by the search.
Admission discovers any omitted hardware values from the cluster. | | Optional: \{\}
| -| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10;
an explicit empty workload receives the same shape from admission. An explicit
workload.static: \{\} opts out of that shape, leaving the workload to
spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the
fields it names.
The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a
KV-load ratio search. | | Optional: \{\}
| -| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search.
When omitted, admission supplies the object. Its mode defaults to Optimize, and admission
supplies the conditional scalar metric. | | Optional: \{\}
| -| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate selects the execution artifact and supplies native AISimulate input. | | Optional: \{\}
| +| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10;
an explicit empty workload receives the same shape. workload.static: \{\} opts out of all
traffic defaults. Any other static object is completed with its missing defaults.
spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over
every lowered traffic field it names.
The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction
search. Lowering independently defaults optimizer.max_trials to 320. | | Optional: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization. When omitted,
admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | Optional: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. | | Optional: \{\}
| | `recommendation` _[RecommendationSpec](#recommendationspec)_ | Recommendation controls bounded projection into DGDC resources. | | Optional: \{\}
| @@ -3480,9 +3495,9 @@ _Appears in:_ | `model` _[ModelSpec](#modelspec)_ | Model identifies the model whose deployment configurations are evaluated. | | Optional: \{\}
| | `backends` _[Backend](#backend) array_ | Backends lists one or more inference backends searched by AISimulate and used by generated candidates. | | Enum: [vllm sglang trtllm]
MinItems: 1
Optional: \{\}
| | `hardware` _[HardwareSpec](#v1beta2-hardwarespec)_ | Hardware bounds the accelerator configurations evaluated by the search.
Admission discovers any omitted hardware values from the cluster. | | Optional: \{\}
| -| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10;
an explicit empty workload receives the same shape from admission. An explicit
workload.static: \{\} opts out of that shape, leaving the workload to
spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the
fields it names.
The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a
KV-load ratio search. | | Optional: \{\}
| -| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search.
When omitted, admission supplies the object. Its mode defaults to Optimize, and admission
supplies the conditional scalar metric. | | Optional: \{\}
| -| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate selects the execution artifact and supplies native AISimulate input. | | Optional: \{\}
| +| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10;
an explicit empty workload receives the same shape. workload.static: \{\} opts out of all
traffic defaults. Any other static object is completed with its missing defaults.
spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over
every lowered traffic field it names.
The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction
search. Lowering independently defaults optimizer.max_trials to 320. | | Optional: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization. When omitted,
admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | Optional: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. | | Optional: \{\}
| | `recommendation` _[RecommendationSpec](#recommendationspec)_ | Recommendation controls bounded projection into DGDC resources. | | Optional: \{\}
| | `v1beta1` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#json-v1-apiextensions-k8s-io)_ | V1Beta1 losslessly preserves a native v1beta1 spec while this object is
represented through v1beta2. It is mutually exclusive with native fields.
This field is a read-only conversion view; modify the request through the
native v1beta1 API. | | Type: object
Optional: \{\}
| @@ -3543,9 +3558,9 @@ _Appears in:_ | `model` _[ModelSpec](#modelspec)_ | Model identifies the model whose deployment configurations are evaluated. | | Optional: \{\}
| | `backends` _[Backend](#backend) array_ | Backends lists one or more inference backends searched by AISimulate and used by generated candidates. | | Enum: [vllm sglang trtllm]
MinItems: 1
Optional: \{\}
| | `hardware` _[HardwareSpec](#v1beta2-hardwarespec)_ | Hardware bounds the accelerator configurations evaluated by the search.
Admission discovers any omitted hardware values from the cluster. | | Optional: \{\}
| -| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10;
an explicit empty workload receives the same shape from admission. An explicit
workload.static: \{\} opts out of that shape, leaving the workload to
spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the
fields it names.
The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a
KV-load ratio search. | | Optional: \{\}
| -| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search.
When omitted, admission supplies the object. Its mode defaults to Optimize, and admission
supplies the conditional scalar metric. | | Optional: \{\}
| -| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate selects the execution artifact and supplies native AISimulate input. | | Optional: \{\}
| +| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10;
an explicit empty workload receives the same shape. workload.static: \{\} opts out of all
traffic defaults. Any other static object is completed with its missing defaults.
spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over
every lowered traffic field it names.
The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction
search. Lowering independently defaults optimizer.max_trials to 320. | | Optional: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization. When omitted,
admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | Optional: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. | | Optional: \{\}
| | `recommendation` _[RecommendationSpec](#recommendationspec)_ | Recommendation controls bounded projection into DGDC resources. | | Optional: \{\}
| @@ -3565,6 +3580,7 @@ _Appears in:_ | `phase` _[DGDRRunPhase](#dgdrrunphase)_ | Phase is a concise presentation state. Conditions remain authoritative. | | Enum: [Pending Running Succeeded Failed]
Optional: \{\}
| | `jobRef` _[ResourceReference](#resourcereference)_ | JobRef identifies the controller-owned Job for this run. | | Optional: \{\}
| | `progress` _[DGDRRunProgress](#dgdrrunprogress)_ | Progress contains branch-aware optimizer and evaluation outcome counters. | | Optional: \{\}
| +| `aisimulate` _[AISimulateRunStatus](#aisimulaterunstatus)_ | AISimulate reports the effective input executed by AISimulate. | | Optional: \{\}
| | `candidateRefs` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#localobjectreference-v1-core) array_ | CandidateRefs identifies the current bounded DGDC projection. List order
represents rank, best first, for scalar searches. | | Optional: \{\}
| | `startTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#time-v1-meta)_ | StartTime is when execution started. | | Optional: \{\}
| | `completionTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#time-v1-meta)_ | CompletionTime is present only after a terminal outcome. | | Optional: \{\}
| @@ -3630,23 +3646,39 @@ _Appears in:_ | `gpu` _[GPUHardwareSpec](#gpuhardwarespec)_ | GPU describes accelerator identity and the maximum allocation per candidate.
Omitted fields are resolved independently by admission. | | Optional: \{\}
| -#### KVLoadRatio +#### KVCapacityFractionRange -_Underlying type:_ _float array_ -KVLoadRatio is either a pinned load ratio or a minimum/maximum range. -AISimulate performs mode-specific validation. -_Validation:_ -- MaxItems: 2 -- MinItems: 1 -- items:Maximum: 1 -- items:Minimum: 0 +KVCapacityFractionRange defines the searched fraction of a candidate's KV capacity. + + _Appears in:_ -- [StaticWorkloadSpec](#staticworkloadspec) +- [KVCapacityFractionSpec](#kvcapacityfractionspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `min` _float_ | Min is the lower end of the searched fraction range. | | ExclusiveMinimum: true
Maximum: 1
Minimum: 0
| +| `max` _float_ | Max is the upper end of the searched fraction range. | | ExclusiveMinimum: true
Maximum: 1
Minimum: 0
| + + +#### KVCapacityFractionSpec + +KVCapacityFractionSpec selects either one fixed fraction or a searched range. + + + +_Appears in:_ +- [StaticLoadSpec](#staticloadspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `value` _float_ | Value uses a fixed fraction of the candidate's KV capacity. A value of 1
uses its full estimated capacity. | | ExclusiveMinimum: true
Maximum: 1
Minimum: 0
Optional: \{\}
| +| `range` _[KVCapacityFractionRange](#kvcapacityfractionrange)_ | Range searches fractions between min and max. | | Optional: \{\}
| + #### ModelSpec @@ -3686,9 +3718,9 @@ _Appears in:_ | `model` _[ModelSpec](#modelspec)_ | Model identifies the model whose deployment configurations are evaluated. | | Optional: \{\}
| | `backends` _[Backend](#backend) array_ | Backends lists one or more inference backends searched by AISimulate and used by generated candidates. | | Enum: [vllm sglang trtllm]
MinItems: 1
Optional: \{\}
| | `hardware` _[HardwareSpec](#v1beta2-hardwarespec)_ | Hardware bounds the accelerator configurations evaluated by the search.
Admission discovers any omitted hardware values from the cluster. | | Optional: \{\}
| -| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10;
an explicit empty workload receives the same shape from admission. An explicit
workload.static: \{\} opts out of that shape, leaving the workload to
spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the
fields it names.
The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a
KV-load ratio search. | | Optional: \{\}
| -| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search.
When omitted, admission supplies the object. Its mode defaults to Optimize, and admission
supplies the conditional scalar metric. | | Optional: \{\}
| -| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate selects the execution artifact and supplies native AISimulate input. | | Optional: \{\}
| +| `workload` _[WorkloadSpec](#v1beta2-workloadspec)_ | Workload defines the synthetic traffic model used for every candidate evaluation.
When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10;
an explicit empty workload receives the same shape. workload.static: \{\} opts out of all
traffic defaults. Any other static object is completed with its missing defaults.
spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over
every lowered traffic field it names.
The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction
search. Lowering independently defaults optimizer.max_trials to 320. | | Optional: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization. When omitted,
admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | Optional: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. | | Optional: \{\}
| | `recommendation` _[RecommendationSpec](#recommendationspec)_ | Recommendation controls bounded projection into DGDC resources. | | Optional: \{\}
| @@ -3710,10 +3742,11 @@ _Appears in:_ _Underlying type:_ _string_ -ObjectiveMode selects scalar optimization or a multi-objective Pareto search. +ObjectiveMode selects the optimization mode. Pareto is reserved for a future +release and is not admitted until Candidate publication supports Pareto fronts. _Validation:_ -- Enum: [Optimize Pareto] +- Enum: [Optimize] _Appears in:_ - [ObjectiveSpec](#objectivespec) @@ -3721,7 +3754,7 @@ _Appears in:_ | Field | Description | | --- | --- | | `Optimize` | | -| `Pareto` | | +| `Pareto` | ObjectiveModePareto is reserved for the future Pareto API implementation.
| #### ObjectiveSLASpec @@ -3747,7 +3780,7 @@ _Appears in:_ -ObjectiveSpec defines scalar optimization or a multi-objective Pareto search. +ObjectiveSpec defines scalar optimization. Pareto fields are reserved for a future release. @@ -3759,10 +3792,10 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `mode` _[ObjectiveMode](#objectivemode)_ | Mode selects scalar optimization or a Pareto search. | | Enum: [Optimize Pareto]
Optional: \{\}
| -| `metric` _[ObjectiveMetric](#objectivemetric)_ | Metric is the scalar target when mode is Optimize.
When omitted in Optimize mode, admission defaults it to Throughput. | | Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU]
Optional: \{\}
| -| `metrics` _[ObjectiveMetric](#objectivemetric) array_ | Metrics lists two or more distinct scalar targets when mode is Pareto. | | Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU]
MaxItems: 7
MinItems: 2
Optional: \{\}
| -| `sla` _[ObjectiveSLASpec](#objectiveslaspec)_ | SLA defines per-request latency bounds used to calculate goodput. When omitted, no
bounds apply. spec.aisimulate.parameters.goal.sla overrides the bounds it names. | | Optional: \{\}
| +| `mode` _[ObjectiveMode](#objectivemode)_ | Mode selects scalar optimization. Only Optimize is currently supported. | | Enum: [Optimize]
Optional: \{\}
| +| `metric` _[ObjectiveMetric](#objectivemetric)_ | Metric is the scalar target when mode is Optimize.
When omitted in Optimize mode, admission defaults it to ThroughputPerGPU. | | Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU]
Optional: \{\}
| +| `metrics` _[ObjectiveMetric](#objectivemetric) array_ | Metrics is reserved for the future Pareto implementation. | | Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU]
MaxItems: 2
MinItems: 2
Optional: \{\}
| +| `sla` _[ObjectiveSLASpec](#objectiveslaspec)_ | SLA defines per-request latency bounds used to calculate goodput. Admission defaults an
omitted SLA to TTFT 2000 ms and ITL 30 ms. An explicit empty object opts out of those
defaults. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. | | Optional: \{\}
| #### RecommendationSpec @@ -3781,7 +3814,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `maxCandidates` _integer_ | MaxCandidates is the maximum number of DGDCs owned by one run. Scalar
candidates are ordered best first; Pareto candidates have no scalar rank.
This field does not limit internal trials. | | Maximum: 100
Minimum: 1
Optional: \{\}
| +| `maxCandidates` _integer_ | MaxCandidates is the maximum number of DGDCs owned by one run. Scalar
candidates are ordered best first.
This field does not limit internal trials. | | Maximum: 100
Minimum: 1
Optional: \{\}
| #### ResourceReference @@ -3818,11 +3851,30 @@ _Appears in:_ | `total` _integer_ | Total is the number of planned rounds. | | | +#### StaticLoadSpec + + + +StaticLoadSpec selects exactly one synthetic load model by field presence. + + + +_Appears in:_ +- [StaticWorkloadSpec](#staticworkloadspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `requestsPerSecond` _float_ | RequestsPerSecond is a fixed open-loop arrival rate. | | ExclusiveMinimum: true
Minimum: 0
Optional: \{\}
| +| `concurrency` _integer_ | Concurrency is a fixed closed-loop in-flight request cap. | | Minimum: 1
Optional: \{\}
| +| `kvCapacityFraction` _[KVCapacityFractionSpec](#kvcapacityfractionspec)_ | KVCapacityFraction derives concurrency from each candidate's estimated KV capacity. | | Optional: \{\}
| + + #### StaticWorkloadSpec -StaticWorkloadSpec defines one of AISimulate's synthetic workload shapes. +StaticWorkloadSpec defines AISimulate synthetic traffic. Admission completes a nonempty +object with the omitted static defaults; an explicit empty object opts out of them. @@ -3831,12 +3883,9 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `isl` _integer_ | ISL is the input sequence length in tokens. | | Minimum: 1
Optional: \{\}
| -| `osl` _integer_ | OSL is the output sequence length in tokens. | | Minimum: 1
Optional: \{\}
| -| `numRequestRatio` _float_ | NumRequestRatio sets request count relative to the selected load. | | ExclusiveMinimum: true
Minimum: 0
Optional: \{\}
| -| `requestRate` _float_ | RequestRate is a fixed open-loop arrival rate in requests per second. | | ExclusiveMinimum: true
Minimum: 0
Optional: \{\}
| -| `concurrency` _integer_ | Concurrency is a fixed closed-loop in-flight request cap. | | Minimum: 1
Optional: \{\}
| -| `kvLoadRatio` _[KVLoadRatio](#kvloadratio)_ | KVLoadRatio derives concurrency from candidate KV capacity. A two-value
range requires a bounded AISimulate sweep. | | MaxItems: 2
MinItems: 1
items:Maximum: 1
items:Minimum: 0
Optional: \{\}
| +| `inputTokens` _integer_ | InputTokens is the synthetic request input length in tokens. | | Minimum: 1
Optional: \{\}
| +| `outputTokens` _integer_ | OutputTokens is the synthetic request output length in tokens. | | Minimum: 1
Optional: \{\}
| +| `load` _[StaticLoadSpec](#staticloadspec)_ | Load selects the synthetic load model. When omitted from a nonempty static
workload, admission defaults it to concurrency 10. | | Optional: \{\}
| #### v1beta2 WorkloadSpec @@ -3855,7 +3904,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `static` _[StaticWorkloadSpec](#staticworkloadspec)_ | Static defines one of AISimulate's synthetic workload shapes. Its fields are not
defaulted individually: the default belongs to spec.workload as a whole, so an
explicit workload object keeps exactly the fields it names. | | Optional: \{\}
| +| `static` _[StaticWorkloadSpec](#staticworkloadspec)_ | Static defines AISimulate synthetic traffic. When omitted, admission supplies the
complete static default. An explicit empty object opts out; any nonempty object is
completed with omitted inputTokens, outputTokens and load defaults. | | Optional: \{\}
| diff --git a/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx b/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx index 3b04c19356f4..8004c5dce330 100644 --- a/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx +++ b/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx @@ -10,12 +10,12 @@ subtitle: Full Kubernetes CRD + operator-config API reference, generated from th > [!WARNING] > **Auto-generated from source.** This page is regenerated from the Dynamo operator CRDs by `docs/fern/scripts/gen_kubernetes_api.py` and covers **every** type across all three API packages: the deprecated `nvidia.com/v1alpha1` surface, the supported `nvidia.com/v1beta1` surface, and the operator's own `operator.config.dynamo.nvidia.com/v1alpha1` configuration. The trimmed [DGD](dynamo-graph-deployment.mdx), [DGDR](dynamo-graph-deployment-request.mdx), and [DCD](dynamo-component-deployment.mdx) references cover only user-facing `v1beta1` fields. To edit the surface, change the Go types under `deploy/operator/api/` and let CI regenerate this page. -Dynamo publishes 4 Kubernetes API packages with 208 typed CRD and config sections. +Dynamo publishes 4 Kubernetes API packages with 211 typed CRD and config sections. 71 types 76 types -32 types +35 types 29 types @@ -4523,25 +4523,30 @@ Package v1beta2 contains API Schema definitions for the nvidia.com v1beta2 API g - -AISimulateSpec selects the AISimulate execution artifact and supplies native input. + +AISimulateRunStatus reports the exact public recommendation input used for this execution. **Kind:** `type` -**Appears in:** [DynamoGraphDeploymentRequestNativeSpec](#v1beta2-dynamographdeploymentrequestnativespec), [DynamoGraphDeploymentRequestSpec](#v1beta2-dynamographdeploymentrequestspec), [DynamoGraphDeploymentRunSpec](#dynamographdeploymentrunspec), [NativeSearchInput](#nativesearchinput) +**Appears in:** [DynamoGraphDeploymentRunStatus](#dynamographdeploymentrunstatus) - -Image selects a Dynamo-compatible AISimulate runner. When omitted, admission persists the operator's configured AISimulate image, or the Dynamo planner image of the operator's release, together with the AISimulate version it bundles. -**Validation:** MinLength: 1 Optional: \{\} + +Input is the fully lowered AISimulate input after typed fields, derived defaults and spec.aisimulate.inputOverrides have been merged. Field names and nesting use the upstream AISimulate JSON contract. +See [RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#rawextension-runtime-pkg). +**Validation:** Type: object Optional: \{\} + - -nightly or custom image), and it wins over the mapping. -**Validation:** MinLength: 1 Pattern: `^(0|[1-9][0-9]\{0,3\})\.(0|[1-9][0-9]\{0,3\})\.(0|[1-9][0-9]\{0,3\})$` Optional: \{\} - + +AISimulateSpec supplies public recommendation input to the AISimulate version in the operator's configured +planner image. - -Parameters contains native AISimulate input. Field names and nesting use the upstream AISimulate JSON contract, including snake_case names. + +**Kind:** `type` +**Appears in:** [DynamoGraphDeploymentRequestNativeSpec](#v1beta2-dynamographdeploymentrequestnativespec), [DynamoGraphDeploymentRequestSpec](#v1beta2-dynamographdeploymentrequestspec), [DynamoGraphDeploymentRunSpec](#dynamographdeploymentrunspec), [NativeSearchInput](#nativesearchinput) + + +InputOverrides is an RFC 7386 JSON Merge Patch over the input lowered from the typed fields. It uses the upstream AISimulate JSON contract, including snake_case names. Values here take precedence, and null deletes a lowered field. The dgdr_run member must remain an object, and its operator-owned transport, identity, and runtime fields name, max_candidates, snapshot_dir, runtime_image, runtime_version_override, derive_runtime_images, num_gpus_per_node, and namespace are operator-owned. Request admission rejects them, and the Run controller enforces its values while lowering the immutable snapshot. The operator runs the AISimulate version from its configured planner image; Requests and Runs cannot select a different image or version. See [RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#rawextension-runtime-pkg). **Validation:** Type: object Optional: \{\} @@ -4806,19 +4811,19 @@ See [HardwareSpec](#v1beta2-hardwarespec). -Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; an explicit empty workload receives the same shape from admission. An explicit workload.static: \{\} opts out of that shape, leaving the workload to spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the fields it names. The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a KV-load ratio search. +Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; an explicit empty workload receives the same shape. workload.static: \{\} opts out of all traffic defaults. Any other static object is completed with its missing defaults. spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over every lowered traffic field it names. The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction search. Lowering independently defaults optimizer.max_trials to 320. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} -Objective defines scalar optimization or a multi-objective Pareto search. When omitted, admission supplies the object. Its mode defaults to Optimize, and admission supplies the conditional scalar metric. +Objective defines scalar optimization. When omitted, admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. See [ObjectiveSpec](#objectivespec). **Validation:** Optional: \{\} -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -4888,19 +4893,19 @@ See [HardwareSpec](#v1beta2-hardwarespec). -Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; an explicit empty workload receives the same shape from admission. An explicit workload.static: \{\} opts out of that shape, leaving the workload to spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the fields it names. The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a KV-load ratio search. +Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; an explicit empty workload receives the same shape. workload.static: \{\} opts out of all traffic defaults. Any other static object is completed with its missing defaults. spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over every lowered traffic field it names. The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction search. Lowering independently defaults optimizer.max_trials to 320. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} -Objective defines scalar optimization or a multi-objective Pareto search. When omitted, admission supplies the object. Its mode defaults to Optimize, and admission supplies the conditional scalar metric. +Objective defines scalar optimization. When omitted, admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. See [ObjectiveSpec](#objectivespec). **Validation:** Optional: \{\} -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5010,19 +5015,19 @@ See [HardwareSpec](#v1beta2-hardwarespec). -Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; an explicit empty workload receives the same shape from admission. An explicit workload.static: \{\} opts out of that shape, leaving the workload to spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the fields it names. The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a KV-load ratio search. +Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; an explicit empty workload receives the same shape. workload.static: \{\} opts out of all traffic defaults. Any other static object is completed with its missing defaults. spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over every lowered traffic field it names. The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction search. Lowering independently defaults optimizer.max_trials to 320. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} -Objective defines scalar optimization or a multi-objective Pareto search. When omitted, admission supplies the object. Its mode defaults to Optimize, and admission supplies the conditional scalar metric. +Objective defines scalar optimization. When omitted, admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. See [ObjectiveSpec](#objectivespec). **Validation:** Optional: \{\} -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5059,6 +5064,12 @@ See [DGDRRunProgress](#dgdrrunprogress). **Validation:** Optional: \{\} + +AISimulate reports the effective input executed by AISimulate. +See [AISimulateRunStatus](#aisimulaterunstatus). +**Validation:** Optional: \{\} + + CandidateRefs identifies the current bounded DGDC projection. List order represents rank, best first, for scalar searches. See [LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#localobjectreference-v1-core). @@ -5158,15 +5169,41 @@ See [GPUHardwareSpec](#gpuhardwarespec). - -KVLoadRatio is either a pinned load ratio or a minimum/maximum range. -AISimulate performs mode-specific validation. + +KVCapacityFractionRange defines the searched fraction of a candidate's KV capacity. **Kind:** `type` -**Underlying type:** `float array` -**Validation:** MaxItems: 2; MinItems: 1; items:Maximum: 1; items:Minimum: 0 -**Appears in:** [StaticWorkloadSpec](#staticworkloadspec) +**Appears in:** [KVCapacityFractionSpec](#kvcapacityfractionspec) + + +Min is the lower end of the searched fraction range. +**Validation:** ExclusiveMinimum: true Maximum: 1 Minimum: 0 + + + +Max is the upper end of the searched fraction range. +**Validation:** ExclusiveMinimum: true Maximum: 1 Minimum: 0 + + + + +KVCapacityFractionSpec selects either one fixed fraction or a searched range. + + +**Kind:** `type` +**Appears in:** [StaticLoadSpec](#staticloadspec) + + +Value uses a fixed fraction of the candidate's KV capacity. A value of 1 uses its full estimated capacity. +**Validation:** ExclusiveMinimum: true Maximum: 1 Minimum: 0 Optional: \{\} + + + +Range searches fractions between min and max. +See [KVCapacityFractionRange](#kvcapacityfractionrange). +**Validation:** Optional: \{\} + @@ -5209,19 +5246,19 @@ See [HardwareSpec](#v1beta2-hardwarespec). -Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies a static shape of ISL 1024, OSL 128 and concurrency 10; an explicit empty workload receives the same shape from admission. An explicit workload.static: \{\} opts out of that shape, leaving the workload to spec.aisimulate.parameters.workload. spec.aisimulate.parameters.workload overrides the fields it names. The default evaluates a fixed concurrency, so it creates neither sweep.max_trials nor a KV-load ratio search. +Workload defines the synthetic traffic model used for every candidate evaluation. When omitted, admission supplies 1024 input tokens, 128 output tokens and concurrency 10; an explicit empty workload receives the same shape. workload.static: \{\} opts out of all traffic defaults. Any other static object is completed with its missing defaults. spec.aisimulate.inputOverrides is applied as an RFC 7386 JSON Merge Patch and wins over every lowered traffic field it names. The default evaluates a fixed concurrency, so it does not create a KV-capacity-fraction search. Lowering independently defaults optimizer.max_trials to 320. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} -Objective defines scalar optimization or a multi-objective Pareto search. When omitted, admission supplies the object. Its mode defaults to Optimize, and admission supplies the conditional scalar metric. +Objective defines scalar optimization. When omitted, admission supplies the object. Its mode defaults to Optimize, its scalar metric defaults to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. See [ObjectiveSpec](#objectivespec). **Validation:** Optional: \{\} -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5244,18 +5281,19 @@ ObjectiveMetric is a metric optimized by AISimulate. -ObjectiveMode selects scalar optimization or a multi-objective Pareto search. +ObjectiveMode selects the optimization mode. Pareto is reserved for a future +release and is not admitted until Candidate publication supports Pareto fronts. **Kind:** `enum` **Underlying type:** `string` -**Validation:** Enum: [Optimize Pareto] +**Validation:** Enum: [Optimize] **Appears in:** [ObjectiveSpec](#objectivespec) **Allowed values** - Optimize -- Pareto +- Pareto ObjectiveModePareto is reserved for the future Pareto API implementation. @@ -5283,32 +5321,32 @@ E2EMs is an alternative end-to-end bound in milliseconds. -ObjectiveSpec defines scalar optimization or a multi-objective Pareto search. +ObjectiveSpec defines scalar optimization. Pareto fields are reserved for a future release. **Kind:** `type` **Appears in:** [DynamoGraphDeploymentRequestNativeSpec](#v1beta2-dynamographdeploymentrequestnativespec), [DynamoGraphDeploymentRequestSpec](#v1beta2-dynamographdeploymentrequestspec), [DynamoGraphDeploymentRunSpec](#dynamographdeploymentrunspec), [NativeSearchInput](#nativesearchinput) -Mode selects scalar optimization or a Pareto search. +Mode selects scalar optimization. Only Optimize is currently supported. See [ObjectiveMode](#objectivemode). -**Validation:** Enum: [Optimize Pareto] Optional: \{\} +**Validation:** Enum: [Optimize] Optional: \{\} -Metric is the scalar target when mode is Optimize. When omitted in Optimize mode, admission defaults it to Throughput. +Metric is the scalar target when mode is Optimize. When omitted in Optimize mode, admission defaults it to ThroughputPerGPU. See [ObjectiveMetric](#objectivemetric). **Validation:** Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU] Optional: \{\} -Metrics lists two or more distinct scalar targets when mode is Pareto. +Metrics is reserved for the future Pareto implementation. See [ObjectiveMetric](#objectivemetric). -**Validation:** Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU] MaxItems: 7 MinItems: 2 Optional: \{\} +**Validation:** Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU] MaxItems: 2 MinItems: 2 Optional: \{\} -SLA defines per-request latency bounds used to calculate goodput. When omitted, no bounds apply. spec.aisimulate.parameters.goal.sla overrides the bounds it names. +SLA defines per-request latency bounds used to calculate goodput. Admission defaults an omitted SLA to TTFT 2000 ms and ITL 30 ms. An explicit empty object opts out of those defaults. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. See [ObjectiveSLASpec](#objectiveslaspec). **Validation:** Optional: \{\} @@ -5322,7 +5360,7 @@ RecommendationSpec controls bounded projection into DGDC resources. **Appears in:** [DynamoGraphDeploymentRequestNativeSpec](#v1beta2-dynamographdeploymentrequestnativespec), [DynamoGraphDeploymentRequestSpec](#v1beta2-dynamographdeploymentrequestspec), [DynamoGraphDeploymentRunSpec](#dynamographdeploymentrunspec), [NativeSearchInput](#nativesearchinput) -MaxCandidates is the maximum number of DGDCs owned by one run. Scalar candidates are ordered best first; Pareto candidates have no scalar rank. This field does not limit internal trials. +MaxCandidates is the maximum number of DGDCs owned by one run. Scalar candidates are ordered best first. This field does not limit internal trials. **Validation:** Maximum: 100 Minimum: 1 Optional: \{\} @@ -5361,42 +5399,52 @@ Total is the number of planned rounds. - -StaticWorkloadSpec defines one of AISimulate's synthetic workload shapes. + +StaticLoadSpec selects exactly one synthetic load model by field presence. **Kind:** `type` -**Appears in:** [WorkloadSpec](#v1beta2-workloadspec) +**Appears in:** [StaticWorkloadSpec](#staticworkloadspec) - -ISL is the input sequence length in tokens. -**Validation:** Minimum: 1 Optional: \{\} + +RequestsPerSecond is a fixed open-loop arrival rate. +**Validation:** ExclusiveMinimum: true Minimum: 0 Optional: \{\} - -OSL is the output sequence length in tokens. + +Concurrency is a fixed closed-loop in-flight request cap. **Validation:** Minimum: 1 Optional: \{\} - -NumRequestRatio sets request count relative to the selected load. -**Validation:** ExclusiveMinimum: true Minimum: 0 Optional: \{\} + +KVCapacityFraction derives concurrency from each candidate's estimated KV capacity. +See [KVCapacityFractionSpec](#kvcapacityfractionspec). +**Validation:** Optional: \{\} + - -RequestRate is a fixed open-loop arrival rate in requests per second. -**Validation:** ExclusiveMinimum: true Minimum: 0 Optional: \{\} + +StaticWorkloadSpec defines AISimulate synthetic traffic. Admission completes a nonempty +object with the omitted static defaults; an explicit empty object opts out of them. + + +**Kind:** `type` +**Appears in:** [WorkloadSpec](#v1beta2-workloadspec) + + +InputTokens is the synthetic request input length in tokens. +**Validation:** Minimum: 1 Optional: \{\} - -Concurrency is a fixed closed-loop in-flight request cap. + +OutputTokens is the synthetic request output length in tokens. **Validation:** Minimum: 1 Optional: \{\} - -KVLoadRatio derives concurrency from candidate KV capacity. A two-value range requires a bounded AISimulate sweep. -See [KVLoadRatio](#kvloadratio). -**Validation:** MaxItems: 2 MinItems: 1 items:Maximum: 1 items:Minimum: 0 Optional: \{\} + +Load selects the synthetic load model. When omitted from a nonempty static workload, admission defaults it to concurrency 10. +See [StaticLoadSpec](#staticloadspec). +**Validation:** Optional: \{\} @@ -5408,7 +5456,7 @@ WorkloadSpec defines the synthetic traffic model used for every candidate evalua **Appears in:** [DynamoGraphDeploymentRequestNativeSpec](#v1beta2-dynamographdeploymentrequestnativespec), [DynamoGraphDeploymentRequestSpec](#v1beta2-dynamographdeploymentrequestspec), [DynamoGraphDeploymentRunSpec](#dynamographdeploymentrunspec), [NativeSearchInput](#nativesearchinput) -Static defines one of AISimulate's synthetic workload shapes. Its fields are not defaulted individually: the default belongs to spec.workload as a whole, so an explicit workload object keeps exactly the fields it names. +Static defines AISimulate synthetic traffic. When omitted, admission supplies the complete static default. An explicit empty object opts out; any nonempty object is completed with omitted inputTokens, outputTokens and load defaults. See [StaticWorkloadSpec](#staticworkloadspec). **Validation:** Optional: \{\}