From d895752055251b7f88e90a60d6ea6c406d37622c Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Mon, 26 Oct 2026 11:41:48 +0100 Subject: [PATCH 1/9] operator/dgdr: keep AISimulate execution operator-owned Signed-off-by: Dr. Stefan Schimanski --- .../operator/templates/deployment.yaml | 6 - .../platform/components/operator/values.yaml | 10 -- deploy/operator/api/roundtrip_fuzz_test.go | 14 +- ...ographdeploymentrequest_conversion_test.go | 4 +- .../dynamographdeploymentrequest_types.go | 39 ++--- .../v1beta2/dynamographdeploymentrun_types.go | 16 ++ .../api/v1beta2/zz_generated.deepcopy.go | 29 +++- deploy/operator/cmd/main.go | 23 +-- ...dia.com_dynamographdeploymentrequests.yaml | 27 ++-- .../nvidia.com_dynamographdeploymentruns.yaml | 38 ++--- .../controller/aisimulate/defaults.go | 2 +- .../internal/controller/aisimulate/doc.go | 16 +- .../internal/controller/aisimulate/image.go | 62 -------- .../controller/aisimulate/image_test.go | 52 ------- .../internal/controller/aisimulate/input.go | 38 ++--- .../controller/aisimulate/input_test.go | 76 +++++----- .../internal/controller/aisimulate/paths.go | 2 +- .../aisimulate/release_contract_test.go | 78 ---------- .../internal/controller/suite_envtest_test.go | 7 +- .../internal/dynamo/component_common.go | 4 +- .../dynamo/component_frontend_test.go | 10 +- .../dynamo/component_namespace_test.go | 12 +- .../internal/dynamo/component_worker_test.go | 18 +-- deploy/operator/internal/dynamo/graph.go | 10 +- deploy/operator/internal/dynamo/hash.go | 6 +- .../internal/features/runtime/gate.go | 6 +- .../internal/features/runtime/gate_test.go | 12 +- .../internal/features/runtime/gates.go | 14 +- .../runtimeversion.go} | 8 +- .../runtimeversion_test.go} | 2 +- .../dynamographdeploymentrequest_handler.go | 9 -- ...namographdeploymentrequest_routing_test.go | 42 +----- .../dynamographdeploymentrequest_v1beta2.go | 36 +---- ...graphdeploymentrequest_v1beta2_artifact.go | 57 ------- ...deploymentrequest_v1beta2_artifact_test.go | 120 --------------- ...ographdeploymentrequest_v1beta2_helpers.go | 17 +-- ...namographdeploymentrequest_v1beta2_test.go | 10 +- .../operator/internal/webhook/setup/setup.go | 3 +- .../dynamographdeploymentrequest.go | 4 +- .../dynamographdeploymentrequest_v1beta2.go | 40 ----- ...namographdeploymentrequest_v1beta2_test.go | 105 +------------ ...ploymentrequest_validation_envtest_test.go | 141 +++--------------- .../validation/planner_runtime_features.go | 4 +- .../webhook/validation/shared_helpers.go | 6 +- .../webhook/validation/suite_envtest_test.go | 8 +- .../additional-resources/api-reference-k8s.md | 42 ++++-- .../kubernetes-api/full-api-reference.mdx | 57 ++++--- 47 files changed, 319 insertions(+), 1023 deletions(-) delete mode 100644 deploy/operator/internal/controller/aisimulate/image.go delete mode 100644 deploy/operator/internal/controller/aisimulate/image_test.go delete mode 100644 deploy/operator/internal/controller/aisimulate/release_contract_test.go rename deploy/operator/internal/{versionoverride/versionoverride.go => runtimeversion/runtimeversion.go} (91%) rename deploy/operator/internal/{versionoverride/versionoverride_test.go => runtimeversion/runtimeversion_test.go} (99%) delete mode 100644 deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact.go delete mode 100644 deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_artifact_test.go 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..067855d4341e 100644 --- a/deploy/operator/api/roundtrip_fuzz_test.go +++ b/deploy/operator/api/roundtrip_fuzz_test.go @@ -653,7 +653,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 +671,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..1a8459625559 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go @@ -113,9 +113,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..49aefaca4cec 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -176,40 +176,23 @@ type ObjectiveSpec struct { 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. + // bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. // +optional SLA *ObjectiveSLASpec `json:"sla,omitempty"` } -// AISimulateSpec selects the AISimulate execution artifact and supplies native input. +// AISimulateSpec supplies native 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 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. @@ -245,7 +228,7 @@ type NativeSearchInput struct { // 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 + // spec.aisimulate.inputOverrides.workload. spec.aisimulate.inputOverrides.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. @@ -258,7 +241,7 @@ type NativeSearchInput struct { // +optional Objective *ObjectiveSpec `json:"objective,omitempty"` - // AISimulate selects the execution artifact and supplies native AISimulate input. + // AISimulate supplies native 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..d8719604fd4f 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 native 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..f70db4b8a895 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)) 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..bdad06146d89 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9589,26 +9589,17 @@ 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 native 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 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. @@ -9702,7 +9693,7 @@ spec: 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. + bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. properties: e2eMs: description: E2EMs is an alternative end-to-end bound in milliseconds. @@ -9758,7 +9749,7 @@ spec: 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 + spec.aisimulate.inputOverrides.workload. spec.aisimulate.inputOverrides.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. diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml index 8218fea7e98f..35f4e1be446d 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -61,26 +61,17 @@ 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 native 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 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. @@ -174,7 +165,7 @@ spec: 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. + bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. properties: e2eMs: description: E2EMs is an alternative end-to-end bound in milliseconds. @@ -222,7 +213,7 @@ spec: 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 + spec.aisimulate.inputOverrides.workload. spec.aisimulate.inputOverrides.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. @@ -284,6 +275,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/controller/aisimulate/defaults.go b/deploy/operator/internal/controller/aisimulate/defaults.go index 8aecb2d8d569..0da683538eb6 100644 --- a/deploy/operator/internal/controller/aisimulate/defaults.go +++ b/deploy/operator/internal/controller/aisimulate/defaults.go @@ -32,7 +32,7 @@ const ( ) // 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 +// 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. // 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. diff --git a/deploy/operator/internal/controller/aisimulate/doc.go b/deploy/operator/internal/controller/aisimulate/doc.go index a15cb22952e7..250c39023d0b 100644 --- a/deploy/operator/internal/controller/aisimulate/doc.go +++ b/deploy/operator/internal/controller/aisimulate/doc.go @@ -15,21 +15,21 @@ * limitations under the License. */ -// Package aisimulate lowers a v1beta2 DynamoGraphDeploymentRequest spec into the native -// AISimulate input that a Run executes. +// Package aisimulate lowers a v1beta2 NativeSearchInput snapshot 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. +// Admission does not lower the input: spec.aisimulate.inputOverrides is opaque there, and validating +// the result belongs to the schema bundled in the operator's configured planner image. 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 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 +// spec.aisimulate.inputOverrides 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 +// 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 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 index fbfee0514080..4f0edac20547 100644 --- a/deploy/operator/internal/controller/aisimulate/input.go +++ b/deploy/operator/internal/controller/aisimulate/input.go @@ -27,12 +27,12 @@ import ( "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) +// Lower returns the effective native 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. The only error is malformed inputOverrides (not a JSON +// object); semantic problems are the caller's to validate. +func Lower(spec *v1beta2.NativeSearchInput) (map[string]any, error) { + overrides, err := InputOverrides(spec) if err != nil { return nil, err } @@ -46,23 +46,23 @@ func Lower(spec *v1beta2.DynamoGraphDeploymentRequestSpec) (map[string]any, erro if err := applyDefaults(underlay); err != nil { return nil, err } - input, _ := mergePatch(underlay, parameters).(map[string]any) + input, _ := mergePatch(underlay, overrides).(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 { +// 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.Parameters.Raw - if len(raw) == 0 && spec.AISimulate.Parameters.Object != 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.Parameters.Object); err != nil { - return nil, fmt.Errorf("spec.aisimulate.parameters: %w", err) + if raw, err = json.Marshal(spec.AISimulate.InputOverrides.Object); err != nil { + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: %w", err) } } if len(raw) == 0 { @@ -70,7 +70,7 @@ func Parameters(spec *v1beta2.DynamoGraphDeploymentRequestSpec) (map[string]any, } var value any if err := json.Unmarshal(raw, &value); err != nil { - return nil, fmt.Errorf("spec.aisimulate.parameters: %w", err) + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: %w", err) } switch typed := value.(type) { case nil: @@ -78,13 +78,13 @@ func Parameters(spec *v1beta2.DynamoGraphDeploymentRequestSpec) (map[string]any, case map[string]any: return typed, nil default: - return nil, fmt.Errorf("spec.aisimulate.parameters: must be a JSON object, got %T", value) + return nil, fmt.Errorf("spec.aisimulate.inputOverrides: 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) { +// produce no key, so the native 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 { @@ -215,7 +215,7 @@ func nativeMetrics(metrics []v1beta2.ObjectiveMetric) []string { } // normalize round-trips through JSON so every number is a float64, the type the -// parameters decode to. Merge and validation then see one representation. +// input overrides 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 { diff --git a/deploy/operator/internal/controller/aisimulate/input_test.go b/deploy/operator/internal/controller/aisimulate/input_test.go index dfeff5a150a0..a9a21edeb65a 100644 --- a/deploy/operator/internal/controller/aisimulate/input_test.go +++ b/deploy/operator/internal/controller/aisimulate/input_test.go @@ -60,14 +60,14 @@ func withKVRange(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.Dynamo return spec } -func withParameters(spec *v1beta2.DynamoGraphDeploymentRequestSpec, parameters string) *v1beta2.DynamoGraphDeploymentRequestSpec { - spec.AISimulate = &v1beta2.AISimulateSpec{Parameters: &runtime.RawExtension{Raw: []byte(parameters)}} +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) + input, err := Lower(&spec.NativeSearchInput) if err != nil { t.Fatalf("Lower: %v", err) } @@ -85,7 +85,7 @@ func encode(t *testing.T, input map[string]any) string { func underlay(t *testing.T, spec *v1beta2.DynamoGraphDeploymentRequestSpec) map[string]any { t.Helper() - input, err := Underlay(spec) + input, err := Underlay(&spec.NativeSearchInput) if err != nil { t.Fatalf("Underlay: %v", err) } @@ -111,14 +111,14 @@ func TestLowerModelOnlyRequest(t *testing.T) { } func TestLowerIsDeterministicAndDoesNotMutateTheSpec(t *testing.T) { - const parameters = `{"sweep":{"max_trials":100},"workload":{"request_count":5}}` - spec := withParameters(defaultedSpec(), parameters) + const overrides = `{"sweep":{"max_trials":100},"workload":{"request_count":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.Parameters.Raw) != parameters { - t.Fatalf("parameters mutated: %s", spec.AISimulate.Parameters.Raw) + if string(spec.AISimulate.InputOverrides.Raw) != overrides { + t.Fatalf("overrides mutated: %s", spec.AISimulate.InputOverrides.Raw) } } @@ -128,7 +128,7 @@ func TestLowerExplicitRequest(t *testing.T) { 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}}`) + spec = withInputOverrides(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"},` + @@ -174,25 +174,25 @@ func TestMergePatchSemantics(t *testing.T) { 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}}`)) + input := lower(t, withInputOverrides(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}}`)) + input := lower(t, withInputOverrides(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"}}`)) + input := lower(t, withInputOverrides(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}}}`)) + input := lower(t, withInputOverrides(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)) } @@ -206,7 +206,7 @@ func TestRequestCountDefault(t *testing.T) { } }) 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}}`)) + input := lower(t, withInputOverrides(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)) } @@ -219,7 +219,7 @@ func TestRequestCountDefault(t *testing.T) { } }) t.Run("native null deletes the default", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"workload":{"request_count":null}}`)) + input := lower(t, withInputOverrides(defaultedSpec(), `{"workload":{"request_count":null}}`)) if nested(t, input, "workload", "request_count") != nil { t.Fatal("request_count re-added after a native null") } @@ -227,7 +227,7 @@ func TestRequestCountDefault(t *testing.T) { 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}}`)) + input := lower(t, withInputOverrides(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)) } @@ -264,10 +264,10 @@ func TestMaxTrialsDefault(t *testing.T) { } }) 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 { + if nested(t, lower(t, withInputOverrides(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 { + if nested(t, lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"sweep":{"max_trials":null}}`)), "sweep", "max_trials") != nil { t.Fatal("max_trials re-added after a native null") } }) @@ -294,45 +294,45 @@ func TestStrictSLADerivation(t *testing.T) { } }) t.Run("a native bound alone does not derive it", func(t *testing.T) { - input := lower(t, withParameters(defaultedSpec(), `{"goal":{"sla":{"ttft_ms":500}}}`)) + input := lower(t, withInputOverrides(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 { + if nested(t, lower(t, withInputOverrides(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}}}`)) + input := lower(t, withInputOverrides(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) { +func TestInputOverridesApplyOnceAfterDerivedDefaults(t *testing.T) { t.Run("sweep null removes the complete derived sweep", func(t *testing.T) { - input := lower(t, withParameters(withKVRange(defaultedSpec()), `{"sweep":null}`)) + input := lower(t, withInputOverrides(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}}`)) + input := lower(t, withInputOverrides(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}}`)) + input := lower(t, withInputOverrides(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}}}`)) + t.Run("nothing is derived from values only overrides introduce", func(t *testing.T) { + input := lower(t, withInputOverrides(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)) } @@ -378,21 +378,23 @@ func TestUnderlayKVLoadRatioShapes(t *testing.T) { } } -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) +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 _, parameters := range []string{``, `null`, `{}`} { - if _, err := Lower(withParameters(defaultedSpec(), parameters)); err != nil { - t.Errorf("Lower rejected parameters %q: %v", parameters, err) + 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 TestHasPath(t *testing.T) { - parameters := map[string]any{ + overrides := map[string]any{ "goal": map[string]any{"target": nil, "sla": map[string]any{"ttft_ms": 1.0}}, "workload": map[string]any{"isl": 8.0}, } @@ -407,12 +409,12 @@ func TestHasPath(t *testing.T) { "goal.target.deeper": false, "workload.isl.deeper": false, } { - if got := has(parameters, strings.Split(path, ".")...); got != want { + if got := has(overrides, 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") + t.Error("nothing is named in nil overrides") } } diff --git a/deploy/operator/internal/controller/aisimulate/paths.go b/deploy/operator/internal/controller/aisimulate/paths.go index bd2a9d16b7d8..935a3653b8f6 100644 --- a/deploy/operator/internal/controller/aisimulate/paths.go +++ b/deploy/operator/internal/controller/aisimulate/paths.go @@ -51,7 +51,7 @@ var ( 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 +// 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...) 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/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..bbf3ad192df0 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" @@ -55,45 +54,7 @@ func carrierRequest() *nvidiacomv1beta1.DynamoGraphDeploymentRequest { } } -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 +65,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..35a1a8487df4 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go @@ -50,10 +50,10 @@ 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, recommendation.maxCandidates, workload.static and the +// cluster hardware. workload.static: {} opts out of the static workload. objective.sla has no +// default: an omitted SLA stays absent. +// 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 +123,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 +176,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{} } @@ -232,7 +219,7 @@ func defaultStaticWorkloadSpecV1beta2(static *nvidiacomv1beta2.StaticWorkloadSpe // 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, +// spec.aisimulate.inputOverrides overrides the completed typed value when the input is lowered, // so it never suppresses this default. func defaultObjectiveSpecV1beta2(objective *nvidiacomv1beta2.ObjectiveSpec) { if objective.Mode == "" { @@ -243,19 +230,6 @@ func defaultObjectiveSpecV1beta2(objective *nvidiacomv1beta2.ObjectiveSpec) { } } -// 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 - } -} - func defaultRecommendationSpecV1beta2(recommendation *nvidiacomv1beta2.RecommendationSpec) { if recommendation.MaxCandidates == 0 { recommendation.MaxCandidates = defaultMaxCandidatesV1beta2 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_helpers.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go index fe7bfb6c36f9..c0f04cef3037 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,11 +76,7 @@ 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 @@ -101,7 +88,7 @@ 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 diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go index 25b6dfbf90b5..60678ebe0d61 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go @@ -127,12 +127,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 +150,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 +164,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) } 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_v1beta2.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go index 557129b380e4..b9e94082b0d7 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go @@ -21,7 +21,6 @@ import ( "encoding/json" nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" - "github.com/ai-dynamo/dynamo/deploy/operator/internal/controller/aisimulate" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/util/validation/field" ) @@ -39,44 +38,5 @@ 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")) -} - -// 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. -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")} - } return nil } 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..348406e17365 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go @@ -575,7 +575,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 parameters are stored untouched and never decide hardware defaulting", + name: "v1beta2 input overrides are stored untouched and never decide hardware defaulting", request: v1beta2DGDRForAdmission(func(spec map[string]any) { setV1beta2SearchSpace(spec, map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(4)}) }, nil), @@ -583,13 +583,13 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { 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", "search_space", "hardware_sku"); got != "h100_sxm" { + t.Fatalf("input overrides.search_space.hardware_sku = %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}) }, nil), @@ -689,10 +689,10 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 empty workload static stays empty beside native parameters", + name: "v1beta2 empty workload static stays empty beside native 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, `{"workload":{"isl":512,"osl":128,"concurrency":8,"request_count":100}}`)} }, nil), gpuDiscovery: true, discovered: discoveredH200, @@ -717,9 +717,9 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 native parameters win without rewriting the typed spec", + name: "v1beta2 native 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, `{"workload":{"isl":256}}`)} }, nil), gpuDiscovery: true, discovered: discoveredH200, @@ -727,68 +727,11 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { 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", "aisimulate", "parameters", "workload", "isl"); got != 256 { - t.Fatalf("parameters.workload.isl = %v, want 256", got) + if got := v1beta2Number(t, result, "spec", "aisimulate", "inputOverrides", "workload", "isl"); got != 256 { + t.Fatalf("input overrides.workload.isl = %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"} - }, nil), - 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, - 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", - }, - }, - { - name: "v1beta2 model-only request stores the configured AISimulate image and its override", - request: v1beta2DGDRForAdmission(nil, nil), - gpuDiscovery: true, - discovered: discoveredH200, - check: func(t *testing.T, result *unstructured.Unstructured) { - expectV1beta2AdmissionDefaults(t, result) - }, - }, - { - name: "v1beta2 caller image does not receive the operator's version override", - request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"image": "registry.example.com/aisimulate:1.6.0"} - }, 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") - } - }, - }, - { - name: "v1beta2 caller override wins next to the configured image", - request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "2.0.0"} - }, 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) - } - }, - }, - { name: "v1beta2 hardware omitted is discovered and the budget capped", request: v1beta2DGDRForAdmission(nil, nil), @@ -941,10 +884,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { 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["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, @@ -985,38 +925,6 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { discovered: discoveredH200, wantCELErr: `spec.workload.static.kvLoadRatio: Invalid value: a range needs min < max`, }, - { - name: "v1beta2 versionOverride latest is rejected by the schema", - request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "latest"} - }, 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})$'`, - }, - { - name: "v1beta2 versionOverride with a v prefix is rejected by the schema", - request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "v0.13.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})$'`, - }, - { - name: "v1beta2 versionOverride 0.13.0 is accepted", - request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["aisimulate"] = map[string]any{"versionOverride": "0.13.0"} - }, 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) - } - }, - }, - // v1beta2 UPDATE: defaults stay idempotent, hardware is never rediscovered, and any // spec change is fully validated. { @@ -1047,8 +955,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", "search_space", "hardware_sku"); got != "h100_sxm" { + t.Fatalf("input overrides.search_space.hardware_sku = %q, want h100_sxm", got) } }, }, @@ -1322,13 +1230,11 @@ func beta2DGDRForAdmission( const v1beta2AdmissionModel = "Qwen/Qwen3-0.6B" -// v1beta2NativeHardwareDGDR supplies hardware only through native AISimulate parameters. +// v1beta2NativeHardwareDGDR supplies hardware only through native 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{ + "inputOverrides": map[string]any{ "search_space": map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(8)}, }, } @@ -1356,7 +1262,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 @@ -1466,8 +1372,7 @@ 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) @@ -1484,12 +1389,6 @@ func expectV1beta2AdmissionDefaults(t *testing.T, obj *unstructured.Unstructured 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", "hardware", "gpu", "sku"); got != "h200_sxm" { t.Fatalf("hardware.gpu.sku = %q, want h200_sxm", got) } @@ -1498,15 +1397,15 @@ func expectV1beta2AdmissionDefaults(t *testing.T, obj *unstructured.Unstructured } } -// setV1beta2SearchSpace sets spec.aisimulate.parameters.search_space to searchSpace, replacing -// any parameters the fixture carried. +// setV1beta2SearchSpace sets spec.aisimulate.inputOverrides.search_space to searchSpace, replacing +// any input overrides the fixture carried. func setV1beta2SearchSpace(spec map[string]any, searchSpace 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"] = map[string]any{"search_space": searchSpace} } // 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..7789ea286a24 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, }, }) } 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..4a7782303e3e 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 native 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 native 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 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: \{\}
| +| `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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies native 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: \{\}
| +| `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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies native 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: \{\}
| +| `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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies native 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: \{\}
| @@ -3686,9 +3702,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: \{\}
| +| `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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `aisimulate` _[AISimulateSpec](#aisimulatespec)_ | AISimulate supplies native input to the operator's AISimulate execution bundle. | | Optional: \{\}
| | `recommendation` _[RecommendationSpec](#recommendationspec)_ | Recommendation controls bounded projection into DGDC resources. | | Optional: \{\}
| @@ -3762,7 +3778,7 @@ _Appears in:_ | `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: \{\}
| +| `sla` _[ObjectiveSLASpec](#objectiveslaspec)_ | SLA defines per-request latency bounds used to calculate goodput. When omitted, no
bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. | | Optional: \{\}
| #### RecommendationSpec 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..36869ba9fc50 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 209 typed CRD and config sections. 71 types 76 types -32 types +33 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 native 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 native 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 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,7 +4811,7 @@ 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 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -4818,7 +4823,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies native input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -4888,7 +4893,7 @@ 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 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -4900,7 +4905,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies native input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5010,7 +5015,7 @@ 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 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -5022,7 +5027,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies native 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). @@ -5209,7 +5220,7 @@ 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 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -5221,7 +5232,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate selects the execution artifact and supplies native AISimulate input. +AISimulate supplies native input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5308,7 +5319,7 @@ See [ObjectiveMetric](#objectivemetric). -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. When omitted, no bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. See [ObjectiveSLASpec](#objectiveslaspec). **Validation:** Optional: \{\} From a706033904005558d6a13bf6bb7f096dc0b97f10 Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Mon, 26 Oct 2026 15:52:57 +0100 Subject: [PATCH 2/9] fixup! operator/dgdr: keep AISimulate execution operator-owned Signed-off-by: Dr. Stefan Schimanski --- deploy/operator/api/roundtrip_fuzz_test.go | 20 +- ...ographdeploymentrequest_conversion_test.go | 7 +- .../dynamographdeploymentrequest_types.go | 112 +++-- .../v1beta2/dynamographdeploymentrun_types.go | 2 +- .../api/v1beta2/zz_generated.deepcopy.go | 92 ++-- ...dia.com_dynamographdeploymentrequests.yaml | 125 +++-- .../nvidia.com_dynamographdeploymentruns.yaml | 125 +++-- .../{controller => }/aisimulate/defaults.go | 48 +- .../{controller => }/aisimulate/doc.go | 26 +- .../{controller => }/aisimulate/input.go | 128 +++-- .../internal/aisimulate/input_test.go | 471 ++++++++++++++++++ deploy/operator/internal/aisimulate/paths.go | 59 +++ .../controller/aisimulate/input_test.go | 435 ---------------- .../internal/controller/aisimulate/paths.go | 59 --- .../dynamographdeploymentrequest_v1beta2.go | 35 +- .../dynamographdeploymentrequest_v1beta2.go | 8 + ...namographdeploymentrequest_v1beta2_test.go | 5 + ...ploymentrequest_validation_envtest_test.go | 129 +++-- .../additional-resources/api-reference-k8s.md | 92 ++-- .../kubernetes-api/full-api-reference.mdx | 120 +++-- 20 files changed, 1225 insertions(+), 873 deletions(-) rename deploy/operator/internal/{controller => }/aisimulate/defaults.go (52%) rename deploy/operator/internal/{controller => }/aisimulate/doc.go (53%) rename deploy/operator/internal/{controller => }/aisimulate/input.go (61%) create mode 100644 deploy/operator/internal/aisimulate/input_test.go create mode 100644 deploy/operator/internal/aisimulate/paths.go delete mode 100644 deploy/operator/internal/controller/aisimulate/input_test.go delete mode 100644 deploy/operator/internal/controller/aisimulate/paths.go diff --git a/deploy/operator/api/roundtrip_fuzz_test.go b/deploy/operator/api/roundtrip_fuzz_test.go index 067855d4341e..8ee166230b82 100644 --- a/deploy/operator/api/roundtrip_fuzz_test.go +++ b/deploy/operator/api/roundtrip_fuzz_test.go @@ -60,6 +60,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/conversion" "sigs.k8s.io/randfill" "sigs.k8s.io/yaml" @@ -568,22 +569,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: ptr.To(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}, + }} } } } diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_conversion_test.go index 1a8459625559..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{ diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go index 49aefaca4cec..0b28070cb360 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -64,56 +64,81 @@ 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"` - // RequestRate is a fixed open-loop arrival rate in requests per second. + // Range searches fractions between min and max. + // +optional + Range *KVCapacityFractionRange `json:"range,omitempty"` +} + +// 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 + 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 - KVLoadRatio KVLoadRatio `json:"kvLoadRatio,omitempty"` + 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"` } @@ -156,6 +181,7 @@ type ObjectiveSLASpec struct { // 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" +// +kubebuilder:validation:XValidation:rule="!has(self.metrics) || (size(self.metrics) == 2 && self.metrics.exists(m, m == 'ThroughputPerGPU') && self.metrics.exists(m, m == 'ThroughputPerUser'))",message="Pareto supports exactly ThroughputPerGPU and ThroughputPerUser" // +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 { @@ -168,20 +194,22 @@ type ObjectiveSpec struct { // +optional Metric *ObjectiveMetric `json:"metric,omitempty"` - // Metrics lists two or more distinct scalar targets when mode is Pareto. + // Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. + // The configured AISimulate release supports exactly ThroughputPerGPU and + // ThroughputPerUser through its public recommendation input. // +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.inputOverrides.goal.sla overrides the bounds it names. + // bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. // +optional SLA *ObjectiveSLASpec `json:"sla,omitempty"` } -// AISimulateSpec supplies native input to the AISimulate version in the operator's configured +// AISimulateSpec supplies public recommendation input to the AISimulate version in the operator's configured // planner image. type AISimulateSpec struct { // InputOverrides is an RFC 7386 JSON Merge Patch over the input lowered from @@ -225,13 +253,13 @@ 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials + // nor a KV-capacity-fraction search. // +optional Workload *WorkloadSpec `json:"workload,omitempty"` @@ -241,7 +269,7 @@ type NativeSearchInput struct { // +optional Objective *ObjectiveSpec `json:"objective,omitempty"` - // AISimulate supplies native input to the operator's AISimulate execution bundle. + // 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 d8719604fd4f..280a487756b4 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrun_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrun_types.go @@ -120,7 +120,7 @@ type DGDRRunProgress struct { Candidates *CandidateProgress `json:"candidates,omitempty"` } -// AISimulateRunStatus reports the exact native input used for this execution. +// 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 diff --git a/deploy/operator/api/v1beta2/zz_generated.deepcopy.go b/deploy/operator/api/v1beta2/zz_generated.deepcopy.go index f70db4b8a895..fe68ab15bfa9 100644 --- a/deploy/operator/api/v1beta2/zz_generated.deepcopy.go +++ b/deploy/operator/api/v1beta2/zz_generated.deepcopy.go @@ -580,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. @@ -769,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/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml index bdad06146d89..7f35470c565c 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9589,7 +9589,7 @@ spec: Native v1beta2 fields and the V1Beta1 conversion envelope are mutually exclusive. properties: aisimulate: - description: AISimulate supplies native input to the operator's AISimulate execution bundle. + description: AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. properties: inputOverrides: description: |- @@ -9668,7 +9668,10 @@ spec: - GoodputPerGPU type: string metrics: - description: Metrics lists two or more distinct scalar targets when mode is Pareto. + description: |- + Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. + The configured AISimulate release supports exactly ThroughputPerGPU and + ThroughputPerUser through its public recommendation input. items: description: ObjectiveMetric is a metric optimized by AISimulate. enum: @@ -9680,7 +9683,7 @@ spec: - Goodput - GoodputPerGPU type: string - maxItems: 7 + maxItems: 2 minItems: 2 type: array x-kubernetes-list-type: set @@ -9693,7 +9696,7 @@ spec: sla: description: |- SLA defines per-request latency bounds used to calculate goodput. When omitted, no - bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. + bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. properties: e2eMs: description: E2EMs is an alternative end-to-end bound in milliseconds. @@ -9718,6 +9721,8 @@ spec: 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: Pareto supports exactly ThroughputPerGPU and ThroughputPerUser + rule: '!has(self.metrics) || (size(self.metrics) == 2 && self.metrics.exists(m, m == ''ThroughputPerGPU'') && self.metrics.exists(m, m == ''ThroughputPerUser''))' - 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 @@ -9746,63 +9751,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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials + nor a KV-capacity-fraction search. 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. - format: int32 - minimum: 1 - type: integer - isl: - description: ISL is the input sequence length in tokens. + inputTokens: + description: InputTokens is the synthetic request input 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 35f4e1be446d..c0cbeafc4fe4 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -61,7 +61,7 @@ spec: Run creators resolve optional Request inputs before persisting the Run. properties: aisimulate: - description: AISimulate supplies native input to the operator's AISimulate execution bundle. + description: AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. properties: inputOverrides: description: |- @@ -140,7 +140,10 @@ spec: - GoodputPerGPU type: string metrics: - description: Metrics lists two or more distinct scalar targets when mode is Pareto. + description: |- + Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. + The configured AISimulate release supports exactly ThroughputPerGPU and + ThroughputPerUser through its public recommendation input. items: description: ObjectiveMetric is a metric optimized by AISimulate. enum: @@ -152,7 +155,7 @@ spec: - Goodput - GoodputPerGPU type: string - maxItems: 7 + maxItems: 2 minItems: 2 type: array x-kubernetes-list-type: set @@ -165,7 +168,7 @@ spec: sla: description: |- SLA defines per-request latency bounds used to calculate goodput. When omitted, no - bounds apply. spec.aisimulate.inputOverrides.goal.sla overrides the bounds it names. + bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. properties: e2eMs: description: E2EMs is an alternative end-to-end bound in milliseconds. @@ -190,6 +193,8 @@ spec: 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: Pareto supports exactly ThroughputPerGPU and ThroughputPerUser + rule: '!has(self.metrics) || (size(self.metrics) == 2 && self.metrics.exists(m, m == ''ThroughputPerGPU'') && self.metrics.exists(m, m == ''ThroughputPerUser''))' - 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 @@ -210,63 +215,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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials + nor a KV-capacity-fraction search. 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. - format: int32 - minimum: 1 - type: integer - isl: - description: ISL is the input sequence length in tokens. + inputTokens: + description: InputTokens is the synthetic request input 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/internal/controller/aisimulate/defaults.go b/deploy/operator/internal/aisimulate/defaults.go similarity index 52% rename from deploy/operator/internal/controller/aisimulate/defaults.go rename to deploy/operator/internal/aisimulate/defaults.go index 0da683538eb6..e6721282e186 100644 --- a/deploy/operator/internal/controller/aisimulate/defaults.go +++ b/deploy/operator/internal/aisimulate/defaults.go @@ -22,42 +22,58 @@ 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 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 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 bounds a KV-load range search that has no explicit optimizer.max_trials. + // It matches the trial budget of AISimulate's unified CLI. DefaultMaxTrials int64 = 320 ) -// applyDefaults adds the derived defaults to the typed underlay, before the native +// 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. -// 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. +// 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, workloadPath...) { - if !has(underlay, workloadRequestCount...) && !has(underlay, workloadNumRequestRatio...) { - b.set(float64(DefaultRequestCount), workloadRequestCount) + if has(underlay, trafficPath...) { + if !has(underlay, trafficStopRequests...) { + b.set(float64(DefaultRequestCount), trafficStopRequests) } - kvLoadRatio, _, _ := unstructured.NestedFieldNoCopy(underlay, workloadKVLoadRatio...) - if _, isRange := kvLoadRatio.([]any); isRange && !has(underlay, sweepMaxTrials...) { - b.set(float64(DefaultMaxTrials), sweepMaxTrials) + fraction, _, _ := unstructured.NestedMap(underlay, trafficLoadFraction...) + if has(fraction, "range") && !has(underlay, optimizerMaxTrials...) { + b.set(float64(DefaultMaxTrials), optimizerMaxTrials) } } - if hasSLABound(underlay) && !has(underlay, goalStrictSLA...) { - b.set(true, goalStrictSLA) + 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{goalSLATTFTMs, goalSLAITLMs, goalSLAE2EMs} { + for _, path := range [][]string{evaluationSLATTFTMs, evaluationSLAITLMs, evaluationSLAE2EMs} { if bound, _, _ := unstructured.NestedFieldNoCopy(input, path...); bound != nil { return true } diff --git a/deploy/operator/internal/controller/aisimulate/doc.go b/deploy/operator/internal/aisimulate/doc.go similarity index 53% rename from deploy/operator/internal/controller/aisimulate/doc.go rename to deploy/operator/internal/aisimulate/doc.go index 250c39023d0b..a41ef4bee3d7 100644 --- a/deploy/operator/internal/controller/aisimulate/doc.go +++ b/deploy/operator/internal/aisimulate/doc.go @@ -15,24 +15,26 @@ * limitations under the License. */ -// Package aisimulate lowers a v1beta2 NativeSearchInput snapshot into the native AISimulate -// input that a Run executes. +// Package aisimulate lowers a v1beta2 NativeSearchInput snapshot into the public +// AISimulate recommendation input consumed by `aisimulate recommend --stack dynamo`. // -// Admission does not lower the input: spec.aisimulate.inputOverrides is opaque there, and validating -// the result belongs to the schema bundled in the operator's configured planner image. 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. +// 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 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.inputOverrides is applied on top as a JSON Merge Patch (RFC 7386): native +// 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 the Request selects, not to this package. Reads and writes go +// 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/controller/aisimulate/input.go b/deploy/operator/internal/aisimulate/input.go similarity index 61% rename from deploy/operator/internal/controller/aisimulate/input.go rename to deploy/operator/internal/aisimulate/input.go index 4f0edac20547..1c75e9ca7c97 100644 --- a/deploy/operator/internal/controller/aisimulate/input.go +++ b/deploy/operator/internal/aisimulate/input.go @@ -27,19 +27,32 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -// Lower returns the effective native input: the typed underlay and derived defaults, merged +// 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. The only error is malformed inputOverrides (not a JSON -// object); semantic problems are the caller's to validate. +// 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 } - underlay, err := Underlay(spec) + 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 } @@ -82,85 +95,107 @@ func InputOverrides(spec *v1beta2.NativeSearchInput) (map[string]any, error) { } } -// Underlay translates the typed spec into native input. Typed fields that are unset -// produce no key, so the native defaults and input overrides decide them. +// 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, searchSpaceModelName) + 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, searchSpaceBackend) + b.setStrings(backends, engineBackendChoices) } if spec.Hardware != nil && spec.Hardware.GPU != nil { if sku := spec.Hardware.GPU.SKU; sku != nil && *sku != "" { - b.set(*sku, searchSpaceHardwareSKU) + b.set(*sku, engineHardware) } if budget := spec.Hardware.GPU.Budget; budget != nil { - b.set(int64(*budget), searchSpaceGPUBudget) + b.set(int64(*budget), optimizationHardwareBudget) } } if spec.Workload != nil && spec.Workload.Static != nil { static := spec.Workload.Static - if static.ISL != nil { - b.set(int64(*static.ISL), workloadISL) + if hasStaticWorkloadFields(static) { + b.set("synthetic", trafficSourceType) } - if static.OSL != nil { - b.set(int64(*static.OSL), workloadOSL) + if static.InputTokens != nil { + b.set(int64(*static.InputTokens), trafficSourceInputTokens) } - if static.NumRequestRatio != nil { - b.set(*static.NumRequestRatio, workloadNumRequestRatio) + if static.OutputTokens != nil { + b.set(int64(*static.OutputTokens), trafficSourceOutputTokens) } - 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 + 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) + } } - 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) + 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 { - b.set(nativeMetric(*objective.Metric), goalTarget) + b.set(publicMetric(*objective.Metric), optimizationTarget) } if sla := objective.SLA; sla != nil { if sla.TTFTMs != nil { - b.set(*sla.TTFTMs, goalSLATTFTMs) + b.set(*sla.TTFTMs, evaluationSLATTFTMs) } if sla.ITLMs != nil { - b.set(*sla.ITLMs, goalSLAITLMs) + b.set(*sla.ITLMs, evaluationSLAITLMs) } if sla.E2EMs != nil { - b.set(*sla.E2EMs, goalSLAE2EMs) + b.set(*sla.E2EMs, evaluationSLAE2EMs) } } } return b.root, b.err } -// builder writes native input values, remembering the first failure so the translation reads +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 { @@ -186,11 +221,11 @@ func (b *builder) setStrings(values []string, path []string) { } } -// nativeMetric maps an API metric to AISimulate's goal.target by converting its CamelCase +// publicMetric maps an API metric to AISimulate's optimization.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 { +func publicMetric(metric v1beta2.ObjectiveMetric) string { name := []rune(string(metric)) var out []rune for i, r := range name { @@ -206,17 +241,12 @@ func nativeMetric(metric v1beta2.ObjectiveMetric) string { 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 // input overrides decode to. Merge and validation then see one representation. 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 diff --git a/deploy/operator/internal/aisimulate/input_test.go b/deploy/operator/internal/aisimulate/input_test.go new file mode 100644 index 000000000000..714e7cb7e1e1 --- /dev/null +++ b/deploy/operator/internal/aisimulate/input_test.go @@ -0,0 +1,471 @@ +/* + * 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 structural and admission defaults: +// 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{ + InputTokens: ptrTo(int32(1024)), + OutputTokens: ptrTo(int32(128)), + Load: &v1beta2.StaticLoadSpec{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{ + InputTokens: ptrTo(int32(4000)), + OutputTokens: ptrTo(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":{}}},` + + `"optimization":{"constraints":{"max_candidate_gpus":8},"target":"throughput"},` + + `"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: ptrTo(int32(4000)), + OutputTokens: ptrTo(int32(1000)), + Load: &v1beta2.StaticLoadSpec{RequestsPerSecond: ptrTo(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") != 8000.0 || nested(t, input, "optimization", "constraints", "max_candidate_gpus") != 16.0 { + 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") != 500.0 || 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") != 10.0 { + 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) { + t.Run("added for a KV-load range", func(t *testing.T) { + if nested(t, lower(t, withKVRange(defaultedSpec())), "optimizer", "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, "optimizer") != nil { + t.Fatalf("unexpected optimizer: %s", encode(t, input)) + } + }) + t.Run("not added for a pinned load", func(t *testing.T) { + spec := withKVRange(defaultedSpec()) + spec.Workload.Static.Load.KVCapacityFraction = &v1beta2.KVCapacityFractionSpec{Value: ptrTo(0.5)} + input := lower(t, spec) + if nested(t, input, "optimizer") != nil { + t.Fatalf("unexpected optimizer: %s", encode(t, input)) + } + if nested(t, input, "traffic", "load", "fraction") != 0.5 { + t.Fatal("a one-value ratio should be a scalar") + } + }) + t.Run("override value or null wins", func(t *testing.T) { + if nested(t, lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)), "optimizer", "max_trials") != 7.0 { + t.Fatal("override max_trials ignored") + } + if nested(t, lower(t, withInputOverrides(withKVRange(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("model-only derives nothing", func(t *testing.T) { + input := lower(t, defaultedSpec()) + 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) { + spec := defaultedSpec() + spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{} + input := lower(t, spec) + 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(defaultedSpec(), `{"evaluation":{"sla":{"ttft_ms":500}}}`)) + if nested(t, input, "evaluation", "sla", "ttft_ms") != 500.0 || 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(withKVRange(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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":null,"seed":3}}`)) + if nested(t, input, "optimizer", "max_trials") != nil || nested(t, input, "optimizer", "seed") != 3.0 { + 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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)) + if nested(t, input, "optimizer", "max_trials") != 7.0 { + t.Fatalf("unexpected optimizer: %s", encode(t, input)) + } + }) + t.Run("nothing is derived from values only overrides introduce", func(t *testing.T) { + input := lower(t, withInputOverrides(defaultedSpec(), `{"traffic":{"load":{"fraction":{"range":{"min":0.1,"max":1}}}},"evaluation":{"sla":{"ttft_ms":500}}}`)) + if nested(t, input, "optimizer") != nil || 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: ptrTo("")}} + 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: ptrTo(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 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 := publicMetric(metric); got != want { + t.Errorf("publicMetric(%s) = %q, want %q", metric, got, want) + } + } +} 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/input_test.go b/deploy/operator/internal/controller/aisimulate/input_test.go deleted file mode 100644 index a9a21edeb65a..000000000000 --- a/deploy/operator/internal/controller/aisimulate/input_test.go +++ /dev/null @@ -1,435 +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 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 := `{"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 overrides = `{"sweep":{"max_trials":100},"workload":{"request_count":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{ISL: ptrTo(int32(4000)), OSL: ptrTo(int32(1000)), RequestRate: ptrTo(10.0)} - spec.Objective.Metric = &metric - spec = withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(withKVRange(defaultedSpec()), `{"sweep":{"max_trials":7}}`)), "sweep", "max_trials") != 7.0 { - t.Fatal("native max_trials ignored") - } - if nested(t, lower(t, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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 TestInputOverridesApplyOnceAfterDerivedDefaults(t *testing.T) { - t.Run("sweep null removes the complete derived sweep", func(t *testing.T) { - input := lower(t, withInputOverrides(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, withInputOverrides(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, withInputOverrides(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 overrides introduce", func(t *testing.T) { - input := lower(t, withInputOverrides(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 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 TestHasPath(t *testing.T) { - overrides := 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(overrides, 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 overrides") - } -} - -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 935a3653b8f6..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.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/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go index 35a1a8487df4..becc3fb11dff 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go @@ -37,10 +37,10 @@ const defaultMetricV1beta2 = nvidiacomv1beta2.ObjectiveMetric("Throughput") // 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 + defaultMaxCandidatesV1beta2 int32 = 1 ) // This file defaults the v1beta2 shape of a DynamoGraphDeploymentRequest. It is separate @@ -51,8 +51,9 @@ const ( // // Admission owns every v1beta2 default, and each default has this one owner: backends, // objective.mode, the scalar metric, recommendation.maxCandidates, workload.static and the -// cluster hardware. workload.static: {} opts out of the static workload. objective.sla has no -// default: an omitted SLA stays absent. +// cluster hardware. workload.static: {} opts out of the static workload; a nonempty static +// object receives any missing inputTokens, outputTokens and load defaults. objective.sla has +// no default: an omitted SLA stays absent. // spec.aisimulate.inputOverrides never changes a default. It overrides the defaulted typed // values later, when the input is lowered. @@ -203,18 +204,28 @@ func defaultGPUHardwareSpecV1beta2(gpu *nvidiacomv1beta2.GPUHardwareSpec, defaul } 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 = ptr.To(defaultStaticInputTokensV1beta2) + } + if static.OutputTokens == nil { + static.OutputTokens = ptr.To(defaultStaticOutputTokensV1beta2) + } + if static.Load == nil { + static.Load = &nvidiacomv1beta2.StaticLoadSpec{ + Concurrency: ptr.To(defaultStaticConcurrencyV1beta2), + } + } } // defaultObjectiveSpecV1beta2 defaults the mode and gives an Optimize objective without a diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go index b9e94082b0d7..0a7c411af443 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go @@ -21,6 +21,7 @@ import ( "encoding/json" nvidiacomv1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" + "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" ) @@ -38,5 +39,12 @@ 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())} } + if _, err := aisimulate.Lower(&spec.NativeSearchInput); err != nil { + return field.ErrorList{field.Invalid( + field.NewPath("spec"), + "", + "cannot be lowered to the public AISimulate recommendation input: "+err.Error(), + )} + } return nil } diff --git a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go index 981129efb985..3a65159fa1b6 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go @@ -58,4 +58,9 @@ func TestValidateEnvelopeV1beta2(t *testing.T) { 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") + + v1beta2RequireError(t, validateDynamoGraphDeploymentRequestEnvelopeV1beta2(v1beta2Envelope(`{ + "model":{"name":"meta-llama/Llama-3.1-8B-Instruct"}, + "objective":{"mode":"Pareto","metrics":["Throughput","TTFT"]} + }`), path), "cannot be lowered to the public AISimulate recommendation input") } 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 348406e17365..5a8b5f4d7eb1 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go @@ -577,21 +577,24 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { { name: "v1beta2 input overrides are stored untouched and never decide hardware defaulting", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - setV1beta2SearchSpace(spec, map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(4)}) + 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", "inputOverrides", "search_space", "hardware_sku"); got != "h100_sxm" { - t.Fatalf("input overrides.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 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 +607,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 +678,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,10 +700,10 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { }, }, { - name: "v1beta2 empty workload static stays empty beside native input overrides", + 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{"inputOverrides": 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, @@ -711,24 +722,24 @@ 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 input overrides 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{"inputOverrides": 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", "inputOverrides", "workload", "isl"); got != 256 { - t.Fatalf("input overrides.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) } }, }, @@ -869,7 +880,7 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { { name: "v1beta2 pareto objective receives no scalar metric", request: v1beta2DGDRForAdmission(func(spec map[string]any) { - spec["objective"] = map[string]any{"mode": "Pareto", "metrics": []any{"TTFT", "Throughput"}} + spec["objective"] = map[string]any{"mode": "Pareto", "metrics": []any{"ThroughputPerGPU", "ThroughputPerUser"}} }, nil), gpuDiscovery: true, discovered: discoveredH200, @@ -877,12 +888,21 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { v1beta2Absent(t, result, "spec", "objective", "metric") }, }, + { + name: "v1beta2 pareto rejects objectives absent from the public AISimulate input", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + spec["objective"] = map[string]any{"mode": "Pareto", "metrics": []any{"TTFT", "Throughput"}} + }, nil), + gpuDiscovery: true, + discovered: discoveredH200, + wantCELErr: `spec.objective: Invalid value: Pareto supports exactly ThroughputPerGPU and ThroughputPerUser`, + }, { 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{"inputOverrides": map[string]any{"custom": "kept"}} spec["recommendation"] = map[string]any{"maxCandidates": int64(3)} @@ -917,13 +937,46 @@ 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{"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.load.kvCapacityFraction.range: Invalid value: min must be less than max`, + }, + { + name: "v1beta2 KV capacity fraction must be positive", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + 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.workload.static.load.kvCapacityFraction.value: Invalid value: 0: spec.workload.static.load.kvCapacityFraction.value in body should be greater than 0`, + }, + { + name: "v1beta2 load requires exactly one variant", 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{ + "concurrency": int64(10), "requestsPerSecond": 10.0, + }}} }, nil), gpuDiscovery: true, discovered: discoveredH200, - wantCELErr: `spec.workload.static.kvLoadRatio: Invalid value: a range needs min < max`, + wantCELErr: `spec.workload.static.load: Invalid value: exactly one of requestsPerSecond, concurrency, or kvCapacityFraction must be specified`, + }, + { + name: "v1beta2 empty load is not a second default opt-out", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + spec["workload"] = map[string]any{"static": map[string]any{"load": map[string]any{}}} + }, nil), + gpuDiscovery: true, + discovered: discoveredH200, + 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. @@ -955,8 +1008,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", "inputOverrides", "search_space", "hardware_sku"); got != "h100_sxm" { - t.Fatalf("input overrides.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) } }, }, @@ -1230,12 +1283,13 @@ func beta2DGDRForAdmission( const v1beta2AdmissionModel = "Qwen/Qwen3-0.6B" -// v1beta2NativeHardwareDGDR supplies hardware only through native AISimulate input overrides. +// 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{ "inputOverrides": map[string]any{ - "search_space": map[string]any{"hardware_sku": "h100_sxm", "gpu_budget": int64(8)}, + "engine": map[string]any{"hardware": "h100_sxm"}, + "optimization": map[string]any{"constraints": map[string]any{"max_candidate_gpus": int64(8)}}, }, } }, labels) @@ -1329,12 +1383,14 @@ 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") + if got := v1beta2Number(t, obj, "spec", "workload", "static", "load", "concurrency"); got != 10 { + t.Fatalf("workload.static.load.concurrency = %v, want 10", got) + } v1beta2Absent(t, obj, "spec", "objective", "sla") if got := v1beta2String(t, obj, "spec", "objective", "mode"); got != "Optimize" { t.Fatalf("objective.mode = %q, want Optimize", got) @@ -1358,7 +1414,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 { @@ -1378,9 +1436,7 @@ func expectV1beta2ExplicitValues(t *testing.T, obj *unstructured.Unstructured) { 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. @@ -1397,15 +1453,14 @@ func expectV1beta2AdmissionDefaults(t *testing.T, obj *unstructured.Unstructured } } -// setV1beta2SearchSpace sets spec.aisimulate.inputOverrides.search_space to searchSpace, replacing -// any input overrides 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["inputOverrides"] = map[string]any{"search_space": searchSpace} + aisimulate["inputOverrides"] = overrides } // setBeta2AdmissionHardware names the hardware, so fixtures do not depend on GPU discovery. 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 4a7782303e3e..64d0f89b713d 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 @@ -3230,7 +3230,7 @@ Package v1beta2 contains API Schema definitions for the nvidia.com v1beta2 API g -AISimulateRunStatus reports the exact native input used for this execution. +AISimulateRunStatus reports the exact public recommendation input used for this execution. @@ -3246,7 +3246,7 @@ _Appears in:_ -AISimulateSpec supplies native input to the AISimulate version in the operator's configured +AISimulateSpec supplies public recommendation input to the AISimulate version in the operator's configured planner image. @@ -3453,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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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 supplies native input to the operator's AISimulate execution bundle. | | 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: \{\}
| @@ -3495,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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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 supplies native input to the operator's AISimulate execution bundle. | | 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: \{\}
| @@ -3558,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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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 supplies native input to the operator's AISimulate execution bundle. | | 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: \{\}
| @@ -3646,24 +3646,40 @@ _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 @@ -3702,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.inputOverrides.workload. spec.aisimulate.inputOverrides.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: \{\}
| +| `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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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 supplies native input to the operator's AISimulate execution bundle. | | 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: \{\}
| @@ -3777,8 +3793,8 @@ _Appears in:_ | --- | --- | --- | --- | | `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.inputOverrides.goal.sla overrides the bounds it names. | | Optional: \{\}
| +| `metrics` _[ObjectiveMetric](#objectivemetric) array_ | Metrics selects AISimulate's public Pareto objective pair when mode is Pareto.
The configured AISimulate release supports exactly ThroughputPerGPU and
ThroughputPerUser through its public recommendation input. | | 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. When omitted, no
bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. | | Optional: \{\}
| #### RecommendationSpec @@ -3834,11 +3850,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. @@ -3847,12 +3882,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 @@ -3871,7 +3903,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 36869ba9fc50..468a8b8c9775 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 209 typed CRD and config sections. +Dynamo publishes 4 Kubernetes API packages with 211 typed CRD and config sections. 71 types 76 types -33 types +35 types 29 types @@ -4524,7 +4524,7 @@ Package v1beta2 contains API Schema definitions for the nvidia.com v1beta2 API g -AISimulateRunStatus reports the exact native input used for this execution. +AISimulateRunStatus reports the exact public recommendation input used for this execution. **Kind:** `type` @@ -4538,7 +4538,7 @@ See [RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api -AISimulateSpec supplies native input to the AISimulate version in the operator's configured +AISimulateSpec supplies public recommendation input to the AISimulate version in the operator's configured planner image. @@ -4811,7 +4811,7 @@ 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials nor a KV-capacity-fraction search. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -4823,7 +4823,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate supplies native input to the operator's AISimulate execution bundle. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -4893,7 +4893,7 @@ 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials nor a KV-capacity-fraction search. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -4905,7 +4905,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate supplies native input to the operator's AISimulate execution bundle. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5015,7 +5015,7 @@ 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials nor a KV-capacity-fraction search. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -5027,7 +5027,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate supplies native input to the operator's AISimulate execution bundle. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5169,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: \{\} + @@ -5220,7 +5246,7 @@ 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.inputOverrides.workload. spec.aisimulate.inputOverrides.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 creates neither optimizer.max_trials nor a KV-capacity-fraction search. See [WorkloadSpec](#v1beta2-workloadspec). **Validation:** Optional: \{\} @@ -5232,7 +5258,7 @@ See [ObjectiveSpec](#objectivespec). -AISimulate supplies native input to the operator's AISimulate execution bundle. +AISimulate supplies public recommendation input to the operator's AISimulate execution bundle. See [AISimulateSpec](#aisimulatespec). **Validation:** Optional: \{\} @@ -5313,13 +5339,13 @@ See [ObjectiveMetric](#objectivemetric). -Metrics lists two or more distinct scalar targets when mode is Pareto. +Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. The configured AISimulate release supports exactly ThroughputPerGPU and ThroughputPerUser through its public recommendation input. 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.inputOverrides.goal.sla overrides the bounds it names. +SLA defines per-request latency bounds used to calculate goodput. When omitted, no bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. See [ObjectiveSLASpec](#objectiveslaspec). **Validation:** Optional: \{\} @@ -5372,42 +5398,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: \{\} @@ -5419,7 +5455,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: \{\} From 201fa0b6de0998a283e97a7b173352af39e2af6a Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Mon, 26 Oct 2026 16:22:40 +0100 Subject: [PATCH 3/9] fixup! operator/dgdr: keep AISimulate execution operator-owned Signed-off-by: Dr. Stefan Schimanski --- .../dynamographdeploymentrequest_types.go | 2 +- ...dia.com_dynamographdeploymentrequests.yaml | 2 +- .../nvidia.com_dynamographdeploymentruns.yaml | 2 +- .../internal/aisimulate/input_test.go | 38 ++++++++++++------- ...ploymentrequest_validation_envtest_test.go | 1 - .../additional-resources/api-reference-k8s.md | 2 +- .../kubernetes-api/full-api-reference.mdx | 2 +- 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go index 0b28070cb360..984bd8fd397f 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -190,7 +190,7 @@ type ObjectiveSpec struct { 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"` diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml index 7f35470c565c..951bfb4b3651 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9657,7 +9657,7 @@ spec: 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 diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml index c0cbeafc4fe4..ae9c5cb82047 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -129,7 +129,7 @@ spec: 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 diff --git a/deploy/operator/internal/aisimulate/input_test.go b/deploy/operator/internal/aisimulate/input_test.go index 714e7cb7e1e1..28b83be5d0f5 100644 --- a/deploy/operator/internal/aisimulate/input_test.go +++ b/deploy/operator/internal/aisimulate/input_test.go @@ -29,11 +29,10 @@ import ( func ptrTo[T any](v T) *T { return &v } -// defaultedSpec is a model-only Request as it looks after structural and admission defaults: -// 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. +// 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("Throughput") + 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}, @@ -43,17 +42,27 @@ func defaultedSpec() *v1beta2.DynamoGraphDeploymentRequestSpec { OutputTokens: ptrTo(int32(128)), Load: &v1beta2.StaticLoadSpec{Concurrency: ptrTo(int32(10))}, }}, - Objective: &v1beta2.ObjectiveSpec{Mode: v1beta2.ObjectiveModeOptimize, Metric: &metric}, + Objective: &v1beta2.ObjectiveSpec{ + Mode: v1beta2.ObjectiveModeOptimize, + Metric: &metric, + SLA: &v1beta2.ObjectiveSLASpec{TTFTMs: ptrTo(2000.0), ITLMs: ptrTo(30.0)}, + }, Recommendation: &v1beta2.RecommendationSpec{MaxCandidates: 1}, } } -// withSLA adds the latency bounds the earlier defaults used to supply. +// 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: ptrTo(2000.0), ITLMs: ptrTo(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{ @@ -109,7 +118,8 @@ func nested(t *testing.T, input map[string]any, path ...string) any { 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":{}}},` + - `"optimization":{"constraints":{"max_candidate_gpus":8},"target":"throughput"},` + + `"evaluation":{"sla":{"itl_ms":30,"ttft_ms":2000}},` + + `"optimization":{"constraints":{"max_candidate_gpus":8},"strict_sla":true,"target":"throughput_per_gpu"},` + `"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) @@ -275,22 +285,22 @@ func TestStrictSLADerivation(t *testing.T) { t.Fatal("strict_sla not derived") } }) - t.Run("model-only derives nothing", func(t *testing.T) { - input := lower(t, defaultedSpec()) + 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) { - spec := defaultedSpec() - spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{} - input := lower(t, spec) + 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(defaultedSpec(), `{"evaluation":{"sla":{"ttft_ms":500}}}`)) + input := lower(t, withInputOverrides(withoutSLA(defaultedSpec()), `{"evaluation":{"sla":{"ttft_ms":500}}}`)) if nested(t, input, "evaluation", "sla", "ttft_ms") != 500.0 || nested(t, input, "optimization", "strict_sla") != nil { t.Fatalf("unexpected optimization input: %s", encode(t, input)) } @@ -328,7 +338,7 @@ func TestInputOverridesApplyOnceAfterDerivedDefaults(t *testing.T) { } }) t.Run("nothing is derived from values only overrides introduce", func(t *testing.T) { - input := lower(t, withInputOverrides(defaultedSpec(), `{"traffic":{"load":{"fraction":{"range":{"min":0.1,"max":1}}}},"evaluation":{"sla":{"ttft_ms":500}}}`)) + 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") != nil || nested(t, input, "optimization", "strict_sla") != nil { t.Fatalf("a field was derived from override-only values: %s", encode(t, input)) } 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 5a8b5f4d7eb1..d8bc682b24c5 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go @@ -709,7 +709,6 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { 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) }, }, 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 64d0f89b713d..8bb84e1ea149 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 @@ -3792,7 +3792,7 @@ _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: \{\}
| +| `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 selects AISimulate's public Pareto objective pair when mode is Pareto.
The configured AISimulate release supports exactly ThroughputPerGPU and
ThroughputPerUser through its public recommendation input. | | 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. When omitted, no
bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. | | 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 468a8b8c9775..906f37200025 100644 --- a/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx +++ b/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx @@ -5333,7 +5333,7 @@ See [ObjectiveMode](#objectivemode).
-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: \{\} From 6dba2b07a11854c74e8f529e0aaf0c00206930c7 Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Mon, 26 Oct 2026 16:25:49 +0100 Subject: [PATCH 4/9] fixup! operator/dgdr: keep AISimulate execution operator-owned Signed-off-by: Dr. Stefan Schimanski --- .../dynamographdeploymentrequest_types.go | 11 +++---- ...dia.com_dynamographdeploymentrequests.yaml | 11 +++---- .../nvidia.com_dynamographdeploymentruns.yaml | 11 +++---- .../dynamographdeploymentrequest_v1beta2.go | 30 +++++++++++++------ ...ploymentrequest_validation_envtest_test.go | 11 +++++-- .../additional-resources/api-reference-k8s.md | 10 +++---- .../kubernetes-api/full-api-reference.mdx | 10 +++---- 7 files changed, 57 insertions(+), 37 deletions(-) diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go index 984bd8fd397f..acc6417babda 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -203,8 +203,9 @@ type ObjectiveSpec struct { // +listType=set Metrics []ObjectiveMetric `json:"metrics,omitempty"` - // SLA defines per-request latency bounds used to calculate goodput. When omitted, no - // bounds apply. spec.aisimulate.inputOverrides.evaluation.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"` } @@ -263,9 +264,9 @@ type NativeSearchInput struct { // +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 or a multi-objective Pareto search. 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"` diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml index 951bfb4b3651..3693a8b24fca 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9650,9 +9650,9 @@ 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 or a multi-objective Pareto search. 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: |- @@ -9695,8 +9695,9 @@ spec: type: string sla: description: |- - SLA defines per-request latency bounds used to calculate goodput. When omitted, no - bounds apply. spec.aisimulate.inputOverrides.evaluation.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. diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml index ae9c5cb82047..3dd622f05316 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -122,9 +122,9 @@ 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 or a multi-objective Pareto search. 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: |- @@ -167,8 +167,9 @@ spec: type: string sla: description: |- - SLA defines per-request latency bounds used to calculate goodput. When omitted, no - bounds apply. spec.aisimulate.inputOverrides.evaluation.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. diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go index becc3fb11dff..5ff44a085de0 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go @@ -31,7 +31,7 @@ import ( ) // 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 @@ -40,6 +40,8 @@ const ( defaultStaticInputTokensV1beta2 int32 = 1024 defaultStaticOutputTokensV1beta2 int32 = 128 defaultStaticConcurrencyV1beta2 int32 = 10 + defaultSLATTFTMsV1beta2 = 2000.0 + defaultSLAITLMsV1beta2 = 30.0 defaultMaxCandidatesV1beta2 int32 = 1 ) @@ -50,10 +52,10 @@ 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 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 has -// no default: an omitted SLA stays absent. +// 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. @@ -228,10 +230,10 @@ func defaultStaticWorkloadSpecV1beta2(static *nvidiacomv1beta2.StaticWorkloadSpe } } -// 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.inputOverrides 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 @@ -239,6 +241,16 @@ func defaultObjectiveSpecV1beta2(objective *nvidiacomv1beta2.ObjectiveSpec) { if objective.Mode == nvidiacomv1beta2.ObjectiveModeOptimize && objective.Metric == nil { objective.Metric = ptr.To(defaultMetricV1beta2) } + if objective.SLA == nil { + objective.SLA = &nvidiacomv1beta2.ObjectiveSLASpec{} + defaultObjectiveSLASpecV1beta2(objective.SLA) + } +} + +// defaultObjectiveSLASpecV1beta2 fills the whole-object SLA default admission creates. +func defaultObjectiveSLASpecV1beta2(sla *nvidiacomv1beta2.ObjectiveSLASpec) { + sla.TTFTMs = ptr.To(defaultSLATTFTMsV1beta2) + sla.ITLMs = ptr.To(defaultSLAITLMsV1beta2) } func defaultRecommendationSpecV1beta2(recommendation *nvidiacomv1beta2.RecommendationSpec) { 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 d8bc682b24c5..cb27cfad717d 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go @@ -1390,10 +1390,15 @@ func expectV1beta2StructuralDefaults(t *testing.T, obj *unstructured.Unstructure if got := v1beta2Number(t, obj, "spec", "workload", "static", "load", "concurrency"); got != 10 { t.Fatalf("workload.static.load.concurrency = %v, want 10", got) } - v1beta2Absent(t, obj, "spec", "objective", "sla") 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) } @@ -1441,8 +1446,8 @@ func expectV1beta2ExplicitValues(t *testing.T, obj *unstructured.Unstructured) { // 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", "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) 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 8bb84e1ea149..c2d7c5303b88 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 @@ -3454,7 +3454,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search. 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: \{\}
| @@ -3496,7 +3496,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search. 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: \{\}
| @@ -3559,7 +3559,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search. 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: \{\}
| @@ -3719,7 +3719,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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: \{\}
| +| `objective` _[ObjectiveSpec](#objectivespec)_ | Objective defines scalar optimization or a multi-objective Pareto search. 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: \{\}
| @@ -3794,7 +3794,7 @@ _Appears in:_ | `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 ThroughputPerGPU. | | Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU]
Optional: \{\}
| | `metrics` _[ObjectiveMetric](#objectivemetric) array_ | Metrics selects AISimulate's public Pareto objective pair when mode is Pareto.
The configured AISimulate release supports exactly ThroughputPerGPU and
ThroughputPerUser through its public recommendation input. | | 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. When omitted, no
bounds apply. spec.aisimulate.inputOverrides.evaluation.sla overrides the bounds it names. | | 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 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 906f37200025..301debba36fd 100644 --- a/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx +++ b/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx @@ -4817,7 +4817,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -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 or a multi-objective Pareto search. 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: \{\} @@ -4899,7 +4899,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -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 or a multi-objective Pareto search. 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: \{\} @@ -5021,7 +5021,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -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 or a multi-objective Pareto search. 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: \{\} @@ -5252,7 +5252,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -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 or a multi-objective Pareto search. 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: \{\} @@ -5345,7 +5345,7 @@ See [ObjectiveMetric](#objectivemetric). -SLA defines per-request latency bounds used to calculate goodput. When omitted, no bounds apply. spec.aisimulate.inputOverrides.evaluation.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: \{\} From 59d80694136073d4c1260faf2161d6f97874a6f1 Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Tue, 27 Oct 2026 09:13:18 +0100 Subject: [PATCH 5/9] pr-16661: pr-4: enforce v1beta2 admission boundaries Signed-off-by: Dr. Stefan Schimanski (cherry picked from commit 9ed993fc070f33cb91e84364bd3e97b75869db4b) --- .../dynamographdeploymentrequest_types.go | 34 ++-- ...dia.com_dynamographdeploymentrequests.yaml | 30 ++-- .../nvidia.com_dynamographdeploymentruns.yaml | 30 ++-- ...deploymentrequest_v1beta2_hardware_test.go | 13 +- ...ographdeploymentrequest_v1beta2_helpers.go | 36 ++-- ...namographdeploymentrequest_v1beta2_test.go | 10 +- ...hdeploymentrequest_carrier_envtest_test.go | 1 + .../dynamographdeploymentrequest_v1beta2.go | 19 +- ...ploymentrequest_v1beta2_input_overrides.go | 72 ++++++++ ...namographdeploymentrequest_v1beta2_test.go | 5 - ...ploymentrequest_validation_envtest_test.go | 168 ++++++++++++++++-- .../webhook/validation/suite_envtest_test.go | 10 ++ .../additional-resources/api-reference-k8s.md | 25 +-- .../kubernetes-api/full-api-reference.mdx | 27 +-- 14 files changed, 363 insertions(+), 117 deletions(-) create mode 100644 deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_input_overrides.go diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go index acc6417babda..a504b6634391 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -143,13 +143,15 @@ type WorkloadSpec struct { 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. @@ -179,13 +181,11 @@ 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" -// +kubebuilder:validation:XValidation:rule="!has(self.metrics) || (size(self.metrics) == 2 && self.metrics.exists(m, m == 'ThroughputPerGPU') && self.metrics.exists(m, m == 'ThroughputPerUser'))",message="Pareto supports exactly ThroughputPerGPU and ThroughputPerUser" +// 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"` @@ -194,9 +194,7 @@ type ObjectiveSpec struct { // +optional Metric *ObjectiveMetric `json:"metric,omitempty"` - // Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. - // The configured AISimulate release supports exactly ThroughputPerGPU and - // ThroughputPerUser through its public recommendation input. + // Metrics is reserved for the future Pareto implementation. // +optional // +kubebuilder:validation:MinItems=2 // +kubebuilder:validation:MaxItems=2 @@ -216,8 +214,14 @@ type AISimulateSpec struct { // 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 operator runs the AISimulate version from its configured planner - // image; Requests and Runs cannot select a different image or version. + // 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 @@ -227,7 +231,7 @@ type AISimulateSpec struct { // 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 @@ -264,7 +268,7 @@ type NativeSearchInput struct { // +optional Workload *WorkloadSpec `json:"workload,omitempty"` - // Objective defines scalar optimization or a multi-objective Pareto search. When omitted, + // 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 diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml index 3693a8b24fca..efb97e3578bf 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9596,8 +9596,14 @@ spec: 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 operator runs the AISimulate version from its configured planner - image; Requests and Runs cannot select a different image or version. + 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 type: object @@ -9650,7 +9656,7 @@ spec: type: object objective: description: |- - Objective defines scalar optimization or a multi-objective Pareto search. When omitted, + 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: @@ -9668,10 +9674,7 @@ spec: - GoodputPerGPU type: string metrics: - description: |- - Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. - The configured AISimulate release supports exactly ThroughputPerGPU and - ThroughputPerUser through its public recommendation input. + description: Metrics is reserved for the future Pareto implementation. items: description: ObjectiveMetric is a metric optimized by AISimulate. enum: @@ -9688,10 +9691,9 @@ spec: 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: |- @@ -9720,21 +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: Pareto supports exactly ThroughputPerGPU and ThroughputPerUser - rule: '!has(self.metrics) || (size(self.metrics) == 2 && self.metrics.exists(m, m == ''ThroughputPerGPU'') && self.metrics.exists(m, m == ''ThroughputPerUser''))' + - 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 diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml index 3dd622f05316..4e132b9fd19b 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -68,8 +68,14 @@ spec: 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 operator runs the AISimulate version from its configured planner - image; Requests and Runs cannot select a different image or version. + 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 type: object @@ -122,7 +128,7 @@ spec: type: object objective: description: |- - Objective defines scalar optimization or a multi-objective Pareto search. When omitted, + 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: @@ -140,10 +146,7 @@ spec: - GoodputPerGPU type: string metrics: - description: |- - Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. - The configured AISimulate release supports exactly ThroughputPerGPU and - ThroughputPerUser through its public recommendation input. + description: Metrics is reserved for the future Pareto implementation. items: description: ObjectiveMetric is a metric optimized by AISimulate. enum: @@ -160,10 +163,9 @@ spec: 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: |- @@ -192,21 +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: Pareto supports exactly ThroughputPerGPU and ThroughputPerUser - rule: '!has(self.metrics) || (size(self.metrics) == 2 && self.metrics.exists(m, m == ''ThroughputPerGPU'') && self.metrics.exists(m, m == ''ThroughputPerUser''))' + - 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 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 c0f04cef3037..39e1e37226e9 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go @@ -79,8 +79,8 @@ func (o DGDRV1beta2Options) resolveDefaults( 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 @@ -94,7 +94,7 @@ func (o DGDRV1beta2Options) resolveDefaults( // 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, @@ -113,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 @@ -127,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))) } @@ -150,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))) diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go index 60678ebe0d61..75164cc1cf19 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_test.go @@ -64,13 +64,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}} + 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(v1beta2Ptr("b200_sxm"), v1beta2Ptr(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) } }) @@ -240,9 +240,9 @@ func TestResolveHardwareByOperationV1beta2(t *testing.T) { {"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, + {"changing to complete hardware verifies the new SKU", admissionv1.Update, full(), hardwareSpecV1beta2(v1beta2Ptr("b200_sxm"), v1beta2Ptr(int32(2))), - hardwareDefaultsV1beta2{}, 0, ""}, + hardwareDefaultsV1beta2{}, 1, "b200_sxm"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { 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 0a7c411af443..ca5093f39251 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2.go @@ -39,12 +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())} } + + allErrs := validateDynamoGraphDeploymentRequestSpecV1beta2(&spec, field.NewPath("spec")) if _, err := aisimulate.Lower(&spec.NativeSearchInput); err != nil { - return field.ErrorList{field.Invalid( + allErrs = append(allErrs, field.Invalid( field.NewPath("spec"), "", "cannot be lowered to the public AISimulate recommendation input: "+err.Error(), - )} + )) + } + return allErrs +} + +// validateDynamoGraphDeploymentRequestSpecV1beta2 validates spec. spec and fldPath must not be nil. +func validateDynamoGraphDeploymentRequestSpecV1beta2( + spec *nvidiacomv1beta2.DynamoGraphDeploymentRequestSpec, + fldPath *field.Path, +) field.ErrorList { + 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 3a65159fa1b6..981129efb985 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_v1beta2_test.go @@ -58,9 +58,4 @@ func TestValidateEnvelopeV1beta2(t *testing.T) { 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") - - v1beta2RequireError(t, validateDynamoGraphDeploymentRequestEnvelopeV1beta2(v1beta2Envelope(`{ - "model":{"name":"meta-llama/Llama-3.1-8B-Instruct"}, - "objective":{"mode":"Pareto","metrics":["Throughput","TTFT"]} - }`), path), "cannot be lowered to the public AISimulate recommendation input") } 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 cb27cfad717d..61697b96d8f4 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), @@ -574,6 +577,17 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { } }, }, + { + name: "v1beta2 explicit hardware is rejected when the SKU cannot be verified", + 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("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) { @@ -742,6 +756,82 @@ func TestDynamoGraphDeploymentRequestValidator_Validate(t *testing.T) { } }, }, + { + 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) { + 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.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 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, + wantWebhook: []string{ + "spec.aisimulate.inputOverrides.dgdr_run: Forbidden: must be an object because the operator owns required execution fields within it", + }, + }, + { + name: "v1beta2 dgdr run input cannot be replaced by a scalar", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": "wrong"}) + }, nil), + gpuDiscovery: true, + discovered: discoveredH200, + wantWebhook: []string{ + "spec.aisimulate.inputOverrides.dgdr_run: Forbidden: must be an object because the operator owns required execution fields within it", + }, + }, + { + name: "v1beta2 dgdr run input cannot be replaced by a list", + request: v1beta2DGDRForAdmission(func(spec map[string]any) { + setV1beta2InputOverrides(spec, map[string]any{"dgdr_run": []any{"wrong"}}) + }, nil), + gpuDiscovery: true, + discovered: discoveredH200, + 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), @@ -785,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) }, @@ -858,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`, }, }, @@ -877,24 +966,22 @@ 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, - check: func(t *testing.T, result *unstructured.Unstructured) { - v1beta2Absent(t, result, "spec", "objective", "metric") - }, + gpuDiscovery: true, + discovered: discoveredH200, + wantSchemaErr: `spec.objective.mode: Unsupported value: "Pareto": supported values: "Optimize"`, }, { - name: "v1beta2 pareto rejects objectives absent from the public AISimulate input", + 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, - wantCELErr: `spec.objective: Invalid value: Pareto supports exactly ThroughputPerGPU and ThroughputPerUser`, + wantCELErr: `spec.objective: Invalid value: metrics is reserved for the future Pareto implementation`, }, { name: "v1beta2 explicit values are all kept", @@ -1082,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 @@ -1132,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) @@ -1163,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" @@ -1280,6 +1403,19 @@ func beta2DGDRForAdmission( return request } +func v1beta2RunForAdmission(model string, labels map[string]string) *nvidiacomv1beta2.DynamoGraphDeploymentRun { + return &nvidiacomv1beta2.DynamoGraphDeploymentRun{ + TypeMeta: metav1.TypeMeta{ + APIVersion: nvidiacomv1beta2.GroupVersion.String(), + Kind: "DynamoGraphDeploymentRun", + }, + ObjectMeta: metav1.ObjectMeta{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 public AISimulate input overrides. diff --git a/deploy/operator/internal/webhook/validation/suite_envtest_test.go b/deploy/operator/internal/webhook/validation/suite_envtest_test.go index 7789ea286a24..8cd00d703e01 100644 --- a/deploy/operator/internal/webhook/validation/suite_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/suite_envtest_test.go @@ -123,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 @@ -298,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 "" @@ -324,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 c2d7c5303b88..99f8f4414d22 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 @@ -3259,7 +3259,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `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 operator runs the AISimulate version from its configured planner
image; Requests and Runs cannot select a different image or version. | | 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 @@ -3454,7 +3454,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | 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: \{\}
| @@ -3496,7 +3496,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | 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: \{\}
| @@ -3559,7 +3559,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | 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: \{\}
| @@ -3719,7 +3719,7 @@ _Appears in:_ | `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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction 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, its scalar metric defaults
to ThroughputPerGPU, and its absent SLA defaults to TTFT 2000 ms and ITL 30 ms. | | 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: \{\}
| @@ -3742,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) @@ -3753,7 +3754,7 @@ _Appears in:_ | Field | Description | | --- | --- | | `Optimize` | | -| `Pareto` | | +| `Pareto` | ObjectiveModePareto is reserved for the future Pareto API implementation.
| #### ObjectiveSLASpec @@ -3779,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. @@ -3791,9 +3792,9 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `mode` _[ObjectiveMode](#objectivemode)_ | Mode selects scalar optimization or a Pareto search. | | Enum: [Optimize Pareto]
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 selects AISimulate's public Pareto objective pair when mode is Pareto.
The configured AISimulate release supports exactly ThroughputPerGPU and
ThroughputPerUser through its public recommendation input. | | Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU]
MaxItems: 2
MinItems: 2
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: \{\}
| @@ -3813,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 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 301debba36fd..8a1bb78dbc56 100644 --- a/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx +++ b/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx @@ -4546,7 +4546,7 @@ planner image. **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 operator runs the AISimulate version from its configured planner image; Requests and Runs cannot select a different image or version. +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: \{\} @@ -4817,7 +4817,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -Objective defines scalar optimization or a multi-objective Pareto search. 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. +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: \{\} @@ -4899,7 +4899,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -Objective defines scalar optimization or a multi-objective Pareto search. 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. +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: \{\} @@ -5021,7 +5021,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -Objective defines scalar optimization or a multi-objective Pareto search. 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. +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: \{\} @@ -5252,7 +5252,7 @@ See [WorkloadSpec](#v1beta2-workloadspec). -Objective defines scalar optimization or a multi-objective Pareto search. 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. +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: \{\} @@ -5281,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. @@ -5320,16 +5321,16 @@ 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: \{\} @@ -5339,7 +5340,7 @@ See [ObjectiveMetric](#objectivemetric). -Metrics selects AISimulate's public Pareto objective pair when mode is Pareto. The configured AISimulate release supports exactly ThroughputPerGPU and ThroughputPerUser through its public recommendation input. +Metrics is reserved for the future Pareto implementation. See [ObjectiveMetric](#objectivemetric). **Validation:** Enum: [Throughput ThroughputPerGPU ThroughputPerUser TTFT E2ELatency Goodput GoodputPerGPU] MaxItems: 2 MinItems: 2 Optional: \{\} @@ -5359,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: \{\} From 3abb546f714dd9a06f8c75ffaf9cd330823a8a72 Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Tue, 27 Oct 2026 17:09:18 +0100 Subject: [PATCH 6/9] fixup! operator/dgdr: keep AISimulate execution operator-owned Signed-off-by: Dr. Stefan Schimanski --- deploy/operator/api/roundtrip_fuzz_test.go | 3 +- .../internal/aisimulate/input_test.go | 30 +++++++++---------- deploy/operator/internal/gpu/heterogeneous.go | 2 +- ...namographdeploymentrequest_routing_test.go | 7 ++--- .../dynamographdeploymentrequest_v1beta2.go | 17 +++++------ ...ographdeploymentrequest_v1beta2_helpers.go | 3 +- ...namographdeploymentrequest_v1beta2_test.go | 22 +++++++------- .../internal/webhook/request_version_test.go | 6 ++-- ...ploymentrequest_validation_envtest_test.go | 15 ++-------- 9 files changed, 43 insertions(+), 62 deletions(-) diff --git a/deploy/operator/api/roundtrip_fuzz_test.go b/deploy/operator/api/roundtrip_fuzz_test.go index 8ee166230b82..d111e14089a4 100644 --- a/deploy/operator/api/roundtrip_fuzz_test.go +++ b/deploy/operator/api/roundtrip_fuzz_test.go @@ -60,7 +60,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" runtimeserializer "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/conversion" "sigs.k8s.io/randfill" "sigs.k8s.io/yaml" @@ -579,7 +578,7 @@ func fuzzBeta2Workload(workload *v1beta2.WorkloadSpec, c randfill.Continue) { static.Load = &v1beta2.StaticLoadSpec{Concurrency: oneOfPtr(c, int32(1), int32(32), int32(1024))} case 3: if c.Bool() { - static.Load = &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{Value: ptr.To(0.5)}} + static.Load = &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{Value: new(0.5)}} } else { static.Load = &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{ Range: &v1beta2.KVCapacityFractionRange{Min: 0.1, Max: 1.0}, diff --git a/deploy/operator/internal/aisimulate/input_test.go b/deploy/operator/internal/aisimulate/input_test.go index 28b83be5d0f5..5f7984b79975 100644 --- a/deploy/operator/internal/aisimulate/input_test.go +++ b/deploy/operator/internal/aisimulate/input_test.go @@ -27,8 +27,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) -func ptrTo[T any](v T) *T { return &v } - // 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 { @@ -36,16 +34,16 @@ func defaultedSpec() *v1beta2.DynamoGraphDeploymentRequestSpec { 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))}}, + Hardware: &v1beta2.HardwareSpec{GPU: &v1beta2.GPUHardwareSpec{SKU: new("h200_sxm"), Budget: new(int32(8))}}, Workload: &v1beta2.WorkloadSpec{Static: &v1beta2.StaticWorkloadSpec{ - InputTokens: ptrTo(int32(1024)), - OutputTokens: ptrTo(int32(128)), - Load: &v1beta2.StaticLoadSpec{Concurrency: ptrTo(int32(10))}, + 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: ptrTo(2000.0), ITLMs: ptrTo(30.0)}, + SLA: &v1beta2.ObjectiveSLASpec{TTFTMs: new(2000.0), ITLMs: new(30.0)}, }, Recommendation: &v1beta2.RecommendationSpec{MaxCandidates: 1}, } @@ -53,7 +51,7 @@ func defaultedSpec() *v1beta2.DynamoGraphDeploymentRequestSpec { // 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: ptrTo(2000.0), ITLMs: ptrTo(30.0)} + spec.Objective.SLA = &v1beta2.ObjectiveSLASpec{TTFTMs: new(2000.0), ITLMs: new(30.0)} return spec } @@ -66,8 +64,8 @@ func withoutSLA(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoG // withKVRange replaces the fixed concurrency with a KV-load range search. func withKVRange(spec *v1beta2.DynamoGraphDeploymentRequestSpec) *v1beta2.DynamoGraphDeploymentRequestSpec { spec.Workload.Static = &v1beta2.StaticWorkloadSpec{ - InputTokens: ptrTo(int32(4000)), - OutputTokens: ptrTo(int32(1000)), + InputTokens: new(int32(4000)), + OutputTokens: new(int32(1000)), Load: &v1beta2.StaticLoadSpec{KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{ Range: &v1beta2.KVCapacityFractionRange{Min: 0.1, Max: 1}, }}, @@ -143,9 +141,9 @@ func TestLowerExplicitRequest(t *testing.T) { spec := withSLA(defaultedSpec()) spec.Backends = []v1beta2.Backend{v1beta2.BackendVLLM, v1beta2.BackendSGLang} spec.Workload.Static = &v1beta2.StaticWorkloadSpec{ - InputTokens: ptrTo(int32(4000)), - OutputTokens: ptrTo(int32(1000)), - Load: &v1beta2.StaticLoadSpec{RequestsPerSecond: ptrTo(10.0)}, + 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}}`) @@ -260,7 +258,7 @@ func TestMaxTrialsDefault(t *testing.T) { }) t.Run("not added for a pinned load", func(t *testing.T) { spec := withKVRange(defaultedSpec()) - spec.Workload.Static.Load.KVCapacityFraction = &v1beta2.KVCapacityFractionSpec{Value: ptrTo(0.5)} + spec.Workload.Static.Load.KVCapacityFraction = &v1beta2.KVCapacityFractionSpec{Value: new(0.5)} input := lower(t, spec) if nested(t, input, "optimizer") != nil { t.Fatalf("unexpected optimizer: %s", encode(t, input)) @@ -366,7 +364,7 @@ func TestUnderlayOmitsUnsetTypedFields(t *testing.T) { if got := encode(t, underlay(t, spec)); got != `{"engine":{"model":"m"}}` { t.Fatalf("got %s", got) } - spec.Hardware = &v1beta2.HardwareSpec{GPU: &v1beta2.GPUHardwareSpec{SKU: ptrTo("")}} + 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) } @@ -375,7 +373,7 @@ func TestUnderlayOmitsUnsetTypedFields(t *testing.T) { func TestUnderlayKVCapacityFractionShapes(t *testing.T) { spec := defaultedSpec() spec.Workload.Static.Load = &v1beta2.StaticLoadSpec{ - KVCapacityFraction: &v1beta2.KVCapacityFractionSpec{Value: ptrTo(0.25)}, + 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") 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/webhook/defaulting/dynamographdeploymentrequest_routing_test.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go index bbf3ad192df0..d39b0093148f 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_routing_test.go @@ -33,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} } @@ -47,7 +46,7 @@ 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)}, }, diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go index 5ff44a085de0..2906f7b53372 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2.go @@ -27,7 +27,6 @@ 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. @@ -198,10 +197,10 @@ 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) } } @@ -218,14 +217,14 @@ func defaultWorkloadSpecV1beta2(workload *nvidiacomv1beta2.WorkloadSpec) { // defaultStaticWorkloadSpecV1beta2 completes a static workload with every omitted default. func defaultStaticWorkloadSpecV1beta2(static *nvidiacomv1beta2.StaticWorkloadSpec) { if static.InputTokens == nil { - static.InputTokens = ptr.To(defaultStaticInputTokensV1beta2) + static.InputTokens = new(defaultStaticInputTokensV1beta2) } if static.OutputTokens == nil { - static.OutputTokens = ptr.To(defaultStaticOutputTokensV1beta2) + static.OutputTokens = new(defaultStaticOutputTokensV1beta2) } if static.Load == nil { static.Load = &nvidiacomv1beta2.StaticLoadSpec{ - Concurrency: ptr.To(defaultStaticConcurrencyV1beta2), + Concurrency: new(defaultStaticConcurrencyV1beta2), } } } @@ -239,7 +238,7 @@ func defaultObjectiveSpecV1beta2(objective *nvidiacomv1beta2.ObjectiveSpec) { 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{} @@ -249,8 +248,8 @@ func defaultObjectiveSpecV1beta2(objective *nvidiacomv1beta2.ObjectiveSpec) { // defaultObjectiveSLASpecV1beta2 fills the whole-object SLA default admission creates. func defaultObjectiveSLASpecV1beta2(sla *nvidiacomv1beta2.ObjectiveSLASpec) { - sla.TTFTMs = ptr.To(defaultSLATTFTMsV1beta2) - sla.ITLMs = ptr.To(defaultSLAITLMsV1beta2) + sla.TTFTMs = new(defaultSLATTFTMsV1beta2) + sla.ITLMs = new(defaultSLAITLMsV1beta2) } func defaultRecommendationSpecV1beta2(recommendation *nvidiacomv1beta2.RecommendationSpec) { diff --git a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go index 39e1e37226e9..d4c02d9d5f10 100644 --- a/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go +++ b/deploy/operator/internal/webhook/defaulting/dynamographdeploymentrequest_v1beta2_helpers.go @@ -191,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 75164cc1cf19..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 @@ -66,7 +64,7 @@ func TestResolveHardwareV1beta2(t *testing.T) { }) 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(v1beta2Ptr("b200_sxm"), v1beta2Ptr(int32(2)))) + got, err := DGDRV1beta2Options{Hardware: d}.resolveHardware(context.Background(), admissionv1.Create, nil, spec(new("b200_sxm"), new(int32(2)))) if err != nil { t.Fatal(err) } @@ -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) } @@ -198,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) @@ -212,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 @@ -227,21 +225,21 @@ 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 verifies the new SKU", admissionv1.Update, - full(), hardwareSpecV1beta2(v1beta2Ptr("b200_sxm"), v1beta2Ptr(int32(2))), + full(), hardwareSpecV1beta2(new("b200_sxm"), new(int32(2))), hardwareDefaultsV1beta2{}, 1, "b200_sxm"}, } for _, tt := range tests { @@ -260,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/validation/dynamographdeploymentrequest_validation_envtest_test.go b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go index 61697b96d8f4..d528278f60ab 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeploymentrequest_validation_envtest_test.go @@ -1405,11 +1405,9 @@ func beta2DGDRForAdmission( func v1beta2RunForAdmission(model string, labels map[string]string) *nvidiacomv1beta2.DynamoGraphDeploymentRun { return &nvidiacomv1beta2.DynamoGraphDeploymentRun{ - TypeMeta: metav1.TypeMeta{ - APIVersion: nvidiacomv1beta2.GroupVersion.String(), - Kind: "DynamoGraphDeploymentRun", - }, - ObjectMeta: metav1.ObjectMeta{Name: "test-v1beta2-dgdr-run", Namespace: "default", Labels: labels}, + 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}, }}, @@ -1500,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...) From ff22d329c0464e7e7fa5e4b0cb4c5f32d373eadb Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Tue, 27 Oct 2026 17:40:23 +0100 Subject: [PATCH 7/9] operator/dgdr: preserve AISimulate override integers Signed-off-by: Dr. Stefan Schimanski --- deploy/operator/internal/aisimulate/input.go | 57 ++++++++++++------- .../internal/aisimulate/input_test.go | 34 ++++++++--- 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/deploy/operator/internal/aisimulate/input.go b/deploy/operator/internal/aisimulate/input.go index 1c75e9ca7c97..54d42bc1c679 100644 --- a/deploy/operator/internal/aisimulate/input.go +++ b/deploy/operator/internal/aisimulate/input.go @@ -18,10 +18,11 @@ package aisimulate import ( + "bytes" "encoding/json" "fmt" + "io" "strings" - "unicode" v1beta2 "github.com/ai-dynamo/dynamo/deploy/operator/api/v1beta2" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -82,9 +83,14 @@ func InputOverrides(spec *v1beta2.NativeSearchInput) (map[string]any, error) { return nil, nil } var value any - if err := json.Unmarshal(raw, &value); err != nil { + 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 @@ -163,7 +169,12 @@ func Underlay(spec *v1beta2.NativeSearchInput) (map[string]any, error) { b.set(TargetPareto, optimizationTarget) } } else if objective.Metric != nil { - b.set(publicMetric(*objective.Metric), optimizationTarget) + 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 { @@ -221,28 +232,30 @@ func (b *builder) setStrings(values []string, path []string) { } } -// publicMetric maps an API metric to AISimulate's optimization.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 publicMetric(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)) +// 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) } - return string(out) } -// normalize round-trips through JSON so every number is a float64, the type the -// input overrides decode to. Merge and validation then see one representation. +// 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 diff --git a/deploy/operator/internal/aisimulate/input_test.go b/deploy/operator/internal/aisimulate/input_test.go index 5f7984b79975..5d187000ef69 100644 --- a/deploy/operator/internal/aisimulate/input_test.go +++ b/deploy/operator/internal/aisimulate/input_test.go @@ -200,7 +200,7 @@ func TestInputOverridesWinOverTheTypedUnderlay(t *testing.T) { }) 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") != 8000.0 || nested(t, input, "optimization", "constraints", "max_candidate_gpus") != 16.0 { + 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)) } }) @@ -212,7 +212,7 @@ func TestInputOverridesWinOverTheTypedUnderlay(t *testing.T) { }) 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") != 500.0 || nested(t, input, "evaluation", "sla", "itl_ms") != 30.0 { + 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)) } }) @@ -226,7 +226,7 @@ func TestRequestCountDefault(t *testing.T) { }) 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") != 10.0 { + 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)) } }) @@ -268,7 +268,7 @@ func TestMaxTrialsDefault(t *testing.T) { } }) t.Run("override value or null wins", func(t *testing.T) { - if nested(t, lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)), "optimizer", "max_trials") != 7.0 { + if nested(t, lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)), "optimizer", "max_trials") != json.Number("7") { t.Fatal("override max_trials ignored") } if nested(t, lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":null}}`)), "optimizer", "max_trials") != nil { @@ -299,7 +299,7 @@ func TestStrictSLADerivation(t *testing.T) { }) 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") != 500.0 || nested(t, input, "optimization", "strict_sla") != nil { + 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)) } }) @@ -325,13 +325,13 @@ func TestInputOverridesApplyOnceAfterDerivedDefaults(t *testing.T) { }) t.Run("max_trials null removes only max_trials", func(t *testing.T) { input := lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":null,"seed":3}}`)) - if nested(t, input, "optimizer", "max_trials") != nil || nested(t, input, "optimizer", "seed") != 3.0 { + 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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)) - if nested(t, input, "optimizer", "max_trials") != 7.0 { + if nested(t, input, "optimizer", "max_trials") != json.Number("7") { t.Fatalf("unexpected optimizer: %s", encode(t, input)) } }) @@ -436,6 +436,19 @@ func TestInputOverridesMustBeAnObject(t *testing.T) { } } +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}, @@ -472,8 +485,11 @@ func TestPublicMetricNames(t *testing.T) { "Goodput": "goodput", "GoodputPerGPU": "goodput_per_gpu", } { - if got := publicMetric(metric); got != want { - t.Errorf("publicMetric(%s) = %q, want %q", metric, got, want) + 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) + } } From 4c9fd22fafe10e3ac833f889bb8ab7fd2b1b7ba5 Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Wed, 28 Oct 2026 14:06:50 +0100 Subject: [PATCH 8/9] pr-16661: pr-4: make trial budget explicit Signed-off-by: Dr. Stefan Schimanski --- .../operator/internal/aisimulate/defaults.go | 12 ++--- .../internal/aisimulate/input_test.go | 50 ++++++++----------- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/deploy/operator/internal/aisimulate/defaults.go b/deploy/operator/internal/aisimulate/defaults.go index e6721282e186..d26bbd54fea5 100644 --- a/deploy/operator/internal/aisimulate/defaults.go +++ b/deploy/operator/internal/aisimulate/defaults.go @@ -27,8 +27,9 @@ const ( // this value when replacing it. DefaultRequestCount int64 = 1000 - // DefaultMaxTrials bounds a KV-load range search that has no explicit optimizer.max_trials. - // It matches the trial budget of AISimulate's unified CLI. + // 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 ) @@ -59,10 +60,9 @@ func applyDefaults(underlay map[string]any) error { if !has(underlay, trafficStopRequests...) { b.set(float64(DefaultRequestCount), trafficStopRequests) } - fraction, _, _ := unstructured.NestedMap(underlay, trafficLoadFraction...) - if has(fraction, "range") && !has(underlay, optimizerMaxTrials...) { - b.set(float64(DefaultMaxTrials), optimizerMaxTrials) - } + } + if !has(underlay, optimizerMaxTrials...) { + b.set(float64(DefaultMaxTrials), optimizerMaxTrials) } if hasSLABound(underlay) && !has(underlay, optimizationStrictSLA...) { diff --git a/deploy/operator/internal/aisimulate/input_test.go b/deploy/operator/internal/aisimulate/input_test.go index 5d187000ef69..81ffb914bee2 100644 --- a/deploy/operator/internal/aisimulate/input_test.go +++ b/deploy/operator/internal/aisimulate/input_test.go @@ -118,6 +118,7 @@ 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) @@ -246,32 +247,25 @@ func TestRequestCountDefault(t *testing.T) { } func TestMaxTrialsDefault(t *testing.T) { - t.Run("added for a KV-load range", func(t *testing.T) { - if nested(t, lower(t, withKVRange(defaultedSpec())), "optimizer", "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, "optimizer") != nil { - t.Fatalf("unexpected optimizer: %s", encode(t, input)) - } - }) - t.Run("not added for a pinned load", func(t *testing.T) { - spec := withKVRange(defaultedSpec()) - spec.Workload.Static.Load.KVCapacityFraction = &v1beta2.KVCapacityFractionSpec{Value: new(0.5)} - input := lower(t, spec) - if nested(t, input, "optimizer") != nil { - t.Fatalf("unexpected optimizer: %s", encode(t, input)) - } - if nested(t, input, "traffic", "load", "fraction") != 0.5 { - t.Fatal("a one-value ratio should be a scalar") - } - }) + 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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)), "optimizer", "max_trials") != json.Number("7") { + 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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":null}}`)), "optimizer", "max_trials") != nil { + 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") } }) @@ -318,26 +312,26 @@ func TestStrictSLADerivation(t *testing.T) { func TestInputOverridesApplyOnceAfterDerivedDefaults(t *testing.T) { t.Run("optimizer null removes the complete derived optimizer", func(t *testing.T) { - input := lower(t, withInputOverrides(withKVRange(defaultedSpec()), `{"optimizer":null}`)) + 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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":null,"seed":3}}`)) + 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(withKVRange(defaultedSpec()), `{"optimizer":{"max_trials":7}}`)) + 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("nothing is derived from values only overrides introduce", func(t *testing.T) { + 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") != nil || nested(t, input, "optimization", "strict_sla") != nil { + 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)) } }) From 730e21b5d62d4eb000a4eb491a00836c39367674 Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Thu, 29 Oct 2026 15:43:40 +0100 Subject: [PATCH 9/9] operator/dgdr: document independent trial budget Signed-off-by: Dr. Stefan Schimanski --- .../api/v1beta2/dynamographdeploymentrequest_types.go | 4 ++-- .../bases/nvidia.com_dynamographdeploymentrequests.yaml | 4 ++-- .../crd/bases/nvidia.com_dynamographdeploymentruns.yaml | 4 ++-- .../additional-resources/api-reference-k8s.md | 8 ++++---- .../pages/reference/kubernetes-api/full-api-reference.mdx | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go index a504b6634391..b0dc3aad1683 100644 --- a/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go +++ b/deploy/operator/api/v1beta2/dynamographdeploymentrequest_types.go @@ -263,8 +263,8 @@ type NativeSearchInput struct { // 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 creates neither optimizer.max_trials - // nor a KV-capacity-fraction search. + // 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"` diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml index efb97e3578bf..03339360d9b6 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentrequests.yaml @@ -9755,8 +9755,8 @@ spec: 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 creates neither optimizer.max_trials - nor a KV-capacity-fraction search. + 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: |- diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml index 4e132b9fd19b..be81839490ba 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeploymentruns.yaml @@ -219,8 +219,8 @@ spec: 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 creates neither optimizer.max_trials - nor a KV-capacity-fraction search. + 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: |- 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 99f8f4414d22..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 @@ -3453,7 +3453,7 @@ _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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction search. | | 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: \{\}
| @@ -3495,7 +3495,7 @@ _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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction search. | | 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: \{\}
| @@ -3558,7 +3558,7 @@ _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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction search. | | 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: \{\}
| @@ -3718,7 +3718,7 @@ _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 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 creates neither optimizer.max_trials
nor a KV-capacity-fraction search. | | 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: \{\}
| 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 8a1bb78dbc56..8004c5dce330 100644 --- a/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx +++ b/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx @@ -4811,7 +4811,7 @@ See [HardwareSpec](#v1beta2-hardwarespec). -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 creates neither optimizer.max_trials nor a KV-capacity-fraction 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: \{\} @@ -4893,7 +4893,7 @@ See [HardwareSpec](#v1beta2-hardwarespec). -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 creates neither optimizer.max_trials nor a KV-capacity-fraction 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: \{\} @@ -5015,7 +5015,7 @@ See [HardwareSpec](#v1beta2-hardwarespec). -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 creates neither optimizer.max_trials nor a KV-capacity-fraction 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: \{\} @@ -5246,7 +5246,7 @@ See [HardwareSpec](#v1beta2-hardwarespec). -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 creates neither optimizer.max_trials nor a KV-capacity-fraction 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: \{\}