diff --git a/deploy/operator/api/v1alpha1/conversion_field_coverage_test.go b/deploy/operator/api/v1alpha1/conversion_field_coverage_test.go index 3a15a1367e2b..c10c84f302c8 100644 --- a/deploy/operator/api/v1alpha1/conversion_field_coverage_test.go +++ b/deploy/operator/api/v1alpha1/conversion_field_coverage_test.go @@ -58,6 +58,7 @@ DynamoComponentDeploymentSpec.experimental.gpuMemoryService.extraClientContainer DynamoComponentDeploymentSpec.experimental.gpuMemoryService.extraClientPods.name DynamoComponentDeploymentSpec.experimental.gpuMemoryService.extraClientPods.podTemplate DynamoComponentDeploymentSpec.experimental.gpuMemoryService.mode +DynamoComponentDeploymentSpec.experimental.grove.forceScalingGroup DynamoComponentDeploymentSpec.frontendSidecar DynamoComponentDeploymentSpec.globalDynamoNamespace DynamoComponentDeploymentSpec.minAvailable @@ -117,6 +118,7 @@ DynamoGraphDeploymentSpec.components.experimental.gpuMemoryService.extraClientCo DynamoGraphDeploymentSpec.components.experimental.gpuMemoryService.extraClientPods.name DynamoGraphDeploymentSpec.components.experimental.gpuMemoryService.extraClientPods.podTemplate DynamoGraphDeploymentSpec.components.experimental.gpuMemoryService.mode +DynamoGraphDeploymentSpec.components.experimental.grove.forceScalingGroup DynamoGraphDeploymentSpec.components.frontendSidecar DynamoGraphDeploymentSpec.components.globalDynamoNamespace DynamoGraphDeploymentSpec.components.minAvailable diff --git a/deploy/operator/api/v1alpha1/dynamocomponentdeployment_conversion_test.go b/deploy/operator/api/v1alpha1/dynamocomponentdeployment_conversion_test.go index cb2424be08a3..cde9a0f05e00 100644 --- a/deploy/operator/api/v1alpha1/dynamocomponentdeployment_conversion_test.go +++ b/deploy/operator/api/v1alpha1/dynamocomponentdeployment_conversion_test.go @@ -623,6 +623,48 @@ func TestDCD_RoundTrip_Experimental(t *testing.T) { } } +// The grove block has no v1alpha1 representation and must survive the spoke +// round-trip both alone (whole hub-only block preserved) and alongside +// alpha-representable fields (sparse preservation merged back). +func TestDCD_RoundTrip_ExperimentalGrove(t *testing.T) { + tests := []struct { + name string + experimental *v1beta1.ExperimentalSpec + }{ + { + name: "grove.forceScalingGroup only", + experimental: &v1beta1.ExperimentalSpec{ + Grove: &v1beta1.GroveSpec{ForceScalingGroup: true}, + }, + }, + { + name: "grove.forceScalingGroup alongside alpha-representable GMS", + experimental: &v1beta1.ExperimentalSpec{ + GPUMemoryService: &v1beta1.GPUMemoryServiceSpec{Mode: v1beta1.GMSModeIntraPod}, + Grove: &v1beta1.GroveSpec{ForceScalingGroup: true}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := &v1beta1.DynamoComponentDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "exp-grouping", Namespace: "ns"}, + Spec: v1beta1.DynamoComponentDeploymentSpec{ + DynamoComponentDeploymentSharedSpec: v1beta1.DynamoComponentDeploymentSharedSpec{ + ComponentName: "exp-grouping", + ComponentType: v1beta1.ComponentTypeWorker, + Experimental: tt.experimental.DeepCopy(), + }, + }, + } + got := dcdRoundTripFromV1beta1(t, src) + if diff := cmp.Diff(src, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("round-trip mismatch (-want +got):\n%s", diff) + } + }) + } +} + func TestDCD_ExperimentalModeValuesAreValidForIntermediateVersion(t *testing.T) { alpha := &DynamoComponentDeployment{ ObjectMeta: metav1.ObjectMeta{Name: "alpha-enums", Namespace: "ns"}, diff --git a/deploy/operator/api/v1alpha1/shared_spec_conversion.go b/deploy/operator/api/v1alpha1/shared_spec_conversion.go index 296548c71d75..8d2af9971d64 100644 --- a/deploy/operator/api/v1alpha1/shared_spec_conversion.go +++ b/deploy/operator/api/v1alpha1/shared_spec_conversion.go @@ -596,6 +596,10 @@ func saveSharedHubOnlySpec(src *v1beta1.DynamoComponentDeploymentSharedSpec, con } if experimentalIsHubOnlyShape(src.Experimental) { save.Experimental = src.Experimental.DeepCopy() + } else if src.Experimental != nil && src.Experimental.Grove != nil { + // The grove block has no v1alpha1 representation; preserve it sparsely + // when the rest of the experimental block converts to alpha fields. + save.Experimental = &v1beta1.ExperimentalSpec{Grove: src.Experimental.Grove.DeepCopy()} } return nil } @@ -1618,6 +1622,11 @@ func restoreSharedHubOnlyFields(dst, preserved *v1beta1.DynamoComponentDeploymen restoreSharedHubOnlyFrontendSidecar(dst, preserved) if dst.Experimental == nil && experimentalIsHubOnlyShape(preserved.Experimental) { dst.Experimental = preserved.Experimental.DeepCopy() + } else if dst.Experimental != nil && preserved.Experimental != nil && + dst.Experimental.Grove == nil && preserved.Experimental.Grove != nil { + // The experimental block was rebuilt from alpha fields (GMS, failover, + // checkpoint); merge back the sparsely preserved hub-only grove block. + dst.Experimental.Grove = preserved.Experimental.Grove.DeepCopy() } return nil } diff --git a/deploy/operator/api/v1beta1/common.go b/deploy/operator/api/v1beta1/common.go index a329f2a76f7e..f1fa4a834871 100644 --- a/deploy/operator/api/v1beta1/common.go +++ b/deploy/operator/api/v1beta1/common.go @@ -164,6 +164,22 @@ const ( GMSModeInterPod GPUMemoryServiceMode = "InterPod" ) +// GroveSpec groups experimental Grove-specific rendering options. +type GroveSpec struct { + // forceScalingGroup opts a single-node component into rendering as a + // PodCliqueScalingGroup with one single-pod PodClique per replica. + // Scaling changes the scaling-group replica count. The first + // `minAvailable` replicas join the deployment's base PodGang together + // with its other base workloads; each replica beyond `minAvailable` + // gets its own PodGang, gang-scheduled separately from the rest of the + // deployment. `false` or omitted means automatic selection (multi-node + // and inter-pod GMS components use a scaling group, other single-node + // components a standalone PodClique), not "force PodClique". + // Immutable after creation. + // +optional + ForceScalingGroup bool `json:"forceScalingGroup,omitempty"` +} + // ExperimentalSpec groups opt-in preview features whose API shape and behavior // may change in breaking ways between v1beta1 releases (including disappearing // without a name-preserving graduation path). Fields placed under @@ -183,6 +199,10 @@ type ExperimentalSpec struct { // +optional Failover *FailoverSpec `json:"failover,omitempty"` + // grove groups Grove-specific rendering options. + // +optional + Grove *GroveSpec `json:"grove,omitempty"` + // checkpoint configures container-image snapshotting and restore for // this component. Set `checkpoint.enabled: true` to opt in. Without // checkpointRef, the DGD controller creates a DGD-scoped DynamoCheckpoint diff --git a/deploy/operator/api/v1beta1/dynamocomponentdeployment_types.go b/deploy/operator/api/v1beta1/dynamocomponentdeployment_types.go index 84afeedf3294..1797b0151b92 100644 --- a/deploy/operator/api/v1beta1/dynamocomponentdeployment_types.go +++ b/deploy/operator/api/v1beta1/dynamocomponentdeployment_types.go @@ -133,8 +133,10 @@ type DynamoComponentDeploymentSharedSpec struct { // +optional Replicas *int32 `json:"replicas,omitempty"` - // minAvailable maps to Grove PodClique minAvailable for single-node and - // Grove PodCliqueScalingGroup minAvailable for multi-node components. + // minAvailable maps to Grove PodCliqueScalingGroup minAvailable for + // components rendered as a scaling group (multi-node, inter-pod GMS, or + // `experimental.grove.forceScalingGroup`; see `UsesPCSG`) and to Grove + // PodClique minAvailable for all other single-node components. // This field determines 1) the minimum number of replicas guaranteed to be // gang-scheduled, and 2) when violating minAvailable replicas triggers gang // termination. @@ -327,6 +329,19 @@ func (s *DynamoComponentDeploymentSharedSpec) IsInterPodGMSEnabled() bool { s.Experimental.GPUMemoryService.Mode == GMSModeInterPod } +// IsGroveScalingGroupForced reports whether the ScalingGroup layout is explicitly requested. +func (s *DynamoComponentDeploymentSharedSpec) IsGroveScalingGroupForced() bool { + return s.Experimental != nil && + s.Experimental.Grove != nil && + s.Experimental.Grove.ForceScalingGroup +} + +// UsesPCSG reports whether Grove renders this component as a +// PodCliqueScalingGroup rather than a standalone PodClique. +func (s *DynamoComponentDeploymentSharedSpec) UsesPCSG() bool { + return s.GetNumberOfNodes() > 1 || s.IsInterPodGMSEnabled() || s.IsGroveScalingGroupForced() +} + // IsInterPodFailoverEnabled reports whether inter-pod GMS failover is configured. func (s *DynamoComponentDeploymentSharedSpec) IsInterPodFailoverEnabled() bool { return s.Experimental != nil && diff --git a/deploy/operator/api/v1beta1/zz_generated.deepcopy.go b/deploy/operator/api/v1beta1/zz_generated.deepcopy.go index dd8efdd35fac..11020af56964 100644 --- a/deploy/operator/api/v1beta1/zz_generated.deepcopy.go +++ b/deploy/operator/api/v1beta1/zz_generated.deepcopy.go @@ -865,6 +865,11 @@ func (in *ExperimentalSpec) DeepCopyInto(out *ExperimentalSpec) { *out = new(FailoverSpec) **out = **in } + if in.Grove != nil { + in, out := &in.Grove, &out.Grove + *out = new(GroveSpec) + **out = **in + } if in.Checkpoint != nil { in, out := &in.Checkpoint, &out.Checkpoint *out = new(ComponentCheckpointConfig) @@ -965,6 +970,21 @@ func (in *GPUMemoryServiceSpec) DeepCopy() *GPUMemoryServiceSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GroveSpec) DeepCopyInto(out *GroveSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GroveSpec. +func (in *GroveSpec) DeepCopy() *GroveSpec { + if in == nil { + return nil + } + out := new(GroveSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HardwareSpec) DeepCopyInto(out *HardwareSpec) { *out = *in diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamocomponentdeployments.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamocomponentdeployments.yaml index f71d6d84ab1e..947a2def3788 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamocomponentdeployments.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamocomponentdeployments.yaml @@ -12186,6 +12186,23 @@ spec: rule: '!has(self.extraClientPods) || size(self.extraClientPods) == 0 || self.mode == ''InterPod''' - message: extraClientPods is reserved for inter-pod GMS and is not implemented yet rule: '!has(self.extraClientPods) || size(self.extraClientPods) == 0' + grove: + description: grove groups Grove-specific rendering options. + properties: + forceScalingGroup: + description: |- + forceScalingGroup opts a single-node component into rendering as a + PodCliqueScalingGroup with one single-pod PodClique per replica. + Scaling changes the scaling-group replica count. The first + `minAvailable` replicas join the deployment's base PodGang together + with its other base workloads; each replica beyond `minAvailable` + gets its own PodGang, gang-scheduled separately from the rest of the + deployment. `false` or omitted means automatic selection (multi-node + and inter-pod GMS components use a scaling group, other single-node + components a standalone PodClique), not "force PodClique". + Immutable after creation. + type: boolean + type: object type: object frontendSidecar: description: |- @@ -12208,8 +12225,10 @@ spec: type: boolean minAvailable: description: |- - minAvailable maps to Grove PodClique minAvailable for single-node and - Grove PodCliqueScalingGroup minAvailable for multi-node components. + minAvailable maps to Grove PodCliqueScalingGroup minAvailable for + components rendered as a scaling group (multi-node, inter-pod GMS, or + `experimental.grove.forceScalingGroup`; see `UsesPCSG`) and to Grove + PodClique minAvailable for all other single-node components. This field determines 1) the minimum number of replicas guaranteed to be gang-scheduled, and 2) when violating minAvailable replicas triggers gang termination. diff --git a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeployments.yaml b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeployments.yaml index f9465892fd73..13c480c90e99 100644 --- a/deploy/operator/config/crd/bases/nvidia.com_dynamographdeployments.yaml +++ b/deploy/operator/config/crd/bases/nvidia.com_dynamographdeployments.yaml @@ -12644,6 +12644,23 @@ spec: rule: '!has(self.extraClientPods) || size(self.extraClientPods) == 0 || self.mode == ''InterPod''' - message: extraClientPods is reserved for inter-pod GMS and is not implemented yet rule: '!has(self.extraClientPods) || size(self.extraClientPods) == 0' + grove: + description: grove groups Grove-specific rendering options. + properties: + forceScalingGroup: + description: |- + forceScalingGroup opts a single-node component into rendering as a + PodCliqueScalingGroup with one single-pod PodClique per replica. + Scaling changes the scaling-group replica count. The first + `minAvailable` replicas join the deployment's base PodGang together + with its other base workloads; each replica beyond `minAvailable` + gets its own PodGang, gang-scheduled separately from the rest of the + deployment. `false` or omitted means automatic selection (multi-node + and inter-pod GMS components use a scaling group, other single-node + components a standalone PodClique), not "force PodClique". + Immutable after creation. + type: boolean + type: object type: object frontendSidecar: description: |- @@ -12666,8 +12683,10 @@ spec: type: boolean minAvailable: description: |- - minAvailable maps to Grove PodClique minAvailable for single-node and - Grove PodCliqueScalingGroup minAvailable for multi-node components. + minAvailable maps to Grove PodCliqueScalingGroup minAvailable for + components rendered as a scaling group (multi-node, inter-pod GMS, or + `experimental.grove.forceScalingGroup`; see `UsesPCSG`) and to Grove + PodClique minAvailable for all other single-node components. This field determines 1) the minimum number of replicas guaranteed to be gang-scheduled, and 2) when violating minAvailable replicas triggers gang termination. diff --git a/deploy/operator/internal/controller/dgd_grove_restart_progress_resolver.go b/deploy/operator/internal/controller/dgd_grove_restart_progress_resolver.go index 8fe171200ef0..367f3e0c494a 100644 --- a/deploy/operator/internal/controller/dgd_grove_restart_progress_resolver.go +++ b/deploy/operator/internal/controller/dgd_grove_restart_progress_resolver.go @@ -82,7 +82,7 @@ func (r *groveRestartProgressResolver) Resolve( // Any component represented by a PodCliqueScalingGroup must use the // PCSG readiness path. Read failures conservatively keep the component // in progress; authoritative readiness returns the error separately. - if component.GetNumberOfNodes() > 1 || component.IsInterPodGMSEnabled() { + if component.UsesPCSG() { isReady, reason, _, _, _ = dynamo.CheckPCSGReady( ctx, r.reader, diff --git a/deploy/operator/internal/controller/dgd_grove_scaler.go b/deploy/operator/internal/controller/dgd_grove_scaler.go index 1f26724a397d..8ca110f79389 100644 --- a/deploy/operator/internal/controller/dgd_grove_scaler.go +++ b/deploy/operator/internal/controller/dgd_grove_scaler.go @@ -70,7 +70,7 @@ func (s *groveScaler) Reconcile( replicas = 0 } - usesPCSG := component.GetNumberOfNodes() > 1 || component.IsInterPodGMSEnabled() + usesPCSG := component.UsesPCSG() resourceName := dynamo.GroveComponentResourceName(dgd, componentName) resourceKind := "PodClique" gvr := consts.PodCliqueGVR diff --git a/deploy/operator/internal/dynamo/graph.go b/deploy/operator/internal/dynamo/graph.go index 669f20678a56..d98359a5c522 100644 --- a/deploy/operator/internal/dynamo/graph.go +++ b/deploy/operator/internal/dynamo/graph.go @@ -1161,6 +1161,8 @@ const ( // multiple ServiceRoles depending on the deployment topology: // // - single-node, no GMS: 1 role (RoleMain) +// - single-node, forceScalingGroup: 1 role (RoleMain with a single pod; +// the PCSG replica count carries the horizontal scale) // - multinode, no GMS: 2 roles (RoleLeader + RoleWorker) // - single-node, inter-pod GMS: 1 engine PCLQ (replicated) + 1 RoleGMS // weight-server PCLQ @@ -1218,6 +1220,8 @@ func expandRolesForComponent(componentName string, componentReplicas *int32, num return expandMultinodeRoles(componentName, numberOfNodes) case isInterPodGMS: return expandSingleNodeGMSRoles(componentName, component.GetTotalEnginePods()) + case component.IsGroveScalingGroupForced(): + return expandSingleNodeScalingGroupRoles(componentName) default: return expandSingleNodeRoles(componentName, componentReplicas) } @@ -1233,6 +1237,12 @@ func expandSingleNodeRoles(componentName string, componentReplicas *int32) []Ser } } +func expandSingleNodeScalingGroupRoles(componentName string) []ServiceRole { + return []ServiceRole{ + {Name: componentName, Role: RoleMain, Replicas: 1}, + } +} + func expandMultinodeRoles(componentName string, numberOfNodes int32) []ServiceRole { return []ServiceRole{ {Name: componentName + "-" + commonconsts.GroveRoleSuffixLeader, Role: RoleLeader, Replicas: 1}, @@ -1274,7 +1284,7 @@ func LongestPodCliqueNameForDGDComponent( component *v1beta1.DynamoComponentDeploymentSharedSpec, ) string { lowerComponentName := strings.ToLower(componentName) - if component == nil || (component.GetNumberOfNodes() <= 1 && !component.IsInterPodGMSEnabled()) { + if component == nil || !component.UsesPCSG() { return lowerComponentName } @@ -1301,7 +1311,7 @@ func PCSNameForDGD(dgdName string, components []v1beta1.DynamoComponentDeploymen componentName := component.ComponentName lowerName := strings.ToLower(componentName) var budget int - if component.GetNumberOfNodes() > 1 || component.IsInterPodGMSEnabled() { + if component.UsesPCSG() { // PCSG = lowerName, PCLQ = longest rendered role name. budget = len(lowerName) + len(LongestPodCliqueNameForDGDComponent(componentName, component)) } else { @@ -2537,7 +2547,7 @@ func GenerateGrovePodCliqueSet( isMultinode := numberOfNodes > 1 isInterPodGMS := component.IsInterPodGMSEnabled() isInterPodFailover := component.IsInterPodFailoverEnabled() - usesPCSG := isMultinode || isInterPodGMS + usesPCSG := component.UsesPCSG() roles := expandRolesForComponent(componentName, component.Replicas, numberOfNodes, component) var cliqueNames []string diff --git a/deploy/operator/internal/dynamo/graph_test.go b/deploy/operator/internal/dynamo/graph_test.go index b3d6d2030d65..a2036f508f81 100644 --- a/deploy/operator/internal/dynamo/graph_test.go +++ b/deploy/operator/internal/dynamo/graph_test.go @@ -5082,6 +5082,23 @@ func TestExpandRolesForService(t *testing.T) { } } +// forceScalingGroup is v1beta1-only, so it gets its own case instead of a +// row in the alpha-shaped table above: the engine PCLQ holds one pod +// regardless of the component replica count (the PCSG carries the scale). +func TestExpandRolesForComponent_SingleNodeForceScalingGroup(t *testing.T) { + component := &v1beta1.DynamoComponentDeploymentSharedSpec{ + Replicas: ptr.To(int32(4)), + Experimental: &v1beta1.ExperimentalSpec{ + Grove: &v1beta1.GroveSpec{ForceScalingGroup: true}, + }, + } + got := expandRolesForComponent("svc", component.Replicas, 1, component) + want := []ServiceRole{{Name: "svc", Role: RoleMain, Replicas: 1}} + if !reflect.DeepEqual(got, want) { + t.Errorf("expandRolesForComponent() = %v, want %v", got, want) + } +} + func TestRoleEnum(t *testing.T) { // Test that role constants are defined correctly if RoleLeader != "leader" { @@ -8780,6 +8797,62 @@ func TestGenerateGrovePodCliqueSet_ComponentMinAvailable(t *testing.T) { } } +// TestGenerateGrovePodCliqueSet_SingleNodeForceScalingGroup pins the +// experimental grove.forceScalingGroup opt-in: a single-node component +// renders as a PCSG whose replica count carries the horizontal scale, with a +// single one-pod engine PCLQ per PCSG replica. +func TestGenerateGrovePodCliqueSet_SingleNodeForceScalingGroup(t *testing.T) { + dgd := &v1alpha1.DynamoGraphDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-dgd", Namespace: "test-ns"}, + Spec: v1alpha1.DynamoGraphDeploymentSpec{ + BackendFramework: "vllm", + Services: map[string]*v1alpha1.DynamoComponentDeploymentSharedSpec{ + "worker": { + ComponentType: commonconsts.ComponentTypeWorker, + Replicas: ptr.To(int32(4)), + MinAvailable: ptr.To(int32(2)), + Resources: &v1alpha1.Resources{ + Limits: &v1alpha1.ResourceItem{GPU: "1"}, + }, + }, + }, + }, + } + + // grove.forceScalingGroup is v1beta1-only, so set it after conversion. + beta := betaDGD(t, dgd) + require.Len(t, beta.Spec.Components, 1) + beta.Spec.Components[0].Experimental = &v1beta1.ExperimentalSpec{ + Grove: &v1beta1.GroveSpec{ForceScalingGroup: true}, + } + + got, err := GenerateGrovePodCliqueSet( + context.Background(), + beta, + &configv1alpha1.OperatorConfiguration{}, + &controller_common.RuntimeConfig{}, + nil, nil, nil, nil, nil, + ) + require.NoError(t, err) + require.NotNil(t, got) + + require.Len(t, got.Spec.Template.Cliques, 1) + clique := got.Spec.Template.Cliques[0] + assert.Equal(t, "worker", clique.Name) + assert.EqualValues(t, 1, clique.Spec.Replicas, "engine PCLQ must hold exactly one pod per PCSG replica") + require.NotNil(t, clique.Spec.MinAvailable) + assert.EqualValues(t, 1, *clique.Spec.MinAvailable) + + require.Len(t, got.Spec.Template.PodCliqueScalingGroupConfigs, 1) + pcsg := got.Spec.Template.PodCliqueScalingGroupConfigs[0] + assert.Equal(t, "worker", pcsg.Name) + assert.Equal(t, []string{"worker"}, pcsg.CliqueNames) + require.NotNil(t, pcsg.Replicas) + assert.EqualValues(t, 4, *pcsg.Replicas, "PCSG replicas must carry the component replica count") + require.NotNil(t, pcsg.MinAvailable) + assert.EqualValues(t, 2, *pcsg.MinAvailable) +} + // TestGenerateGrovePodCliqueSet_MinAvailable_FailoverShadowsAreRedundant pins // the contract that per-rank engine cliques in an inter-pod failover cohort // use MinAvailable=1 even when multinode (numberOfNodes > 1). Replicas here diff --git a/deploy/operator/internal/dynamo/grove.go b/deploy/operator/internal/dynamo/grove.go index 9eaf85ef16b5..e92a650f589c 100644 --- a/deploy/operator/internal/dynamo/grove.go +++ b/deploy/operator/internal/dynamo/grove.go @@ -161,7 +161,7 @@ func evaluateGroveComponents(ctx context.Context, reader client.Reader, dgd *v1b for i := range dgd.Spec.Components { component := &dgd.Spec.Components[i] componentName := component.ComponentName - usesPCSG := component.GetNumberOfNodes() > 1 || component.IsInterPodGMSEnabled() + usesPCSG := component.UsesPCSG() resourceName := GroveComponentResourceName(dgd, componentName) var ok bool diff --git a/deploy/operator/internal/webhook/validation/dynamographdeployment_helpers.go b/deploy/operator/internal/webhook/validation/dynamographdeployment_helpers.go index eed5c2e5b122..3d6e3af3288a 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeployment_helpers.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeployment_helpers.go @@ -161,7 +161,7 @@ func dgdComponentResourceNameLength( combinedLength := len(pcsName) + len(strings.ToLower(componentName)) detail := "PCS name + component name" - if component.GetNumberOfNodes() > 1 || component.IsInterPodGMSEnabled() { + if component.UsesPCSG() { longestPodCliqueName := dynamo.LongestPodCliqueNameForDGDComponent(componentName, component) combinedLength += len(longestPodCliqueName) detail = fmt.Sprintf("PCS name + PCSG name + longest PodClique name %q", longestPodCliqueName) diff --git a/deploy/operator/internal/webhook/validation/dynamographdeployment_validation_envtest_test.go b/deploy/operator/internal/webhook/validation/dynamographdeployment_validation_envtest_test.go index f4a554689363..ec3f23ae1378 100644 --- a/deploy/operator/internal/webhook/validation/dynamographdeployment_validation_envtest_test.go +++ b/deploy/operator/internal/webhook/validation/dynamographdeployment_validation_envtest_test.go @@ -794,6 +794,36 @@ func TestDynamoGraphDeploymentValidator_Validate(t *testing.T) { }), }, + // Grove forceScalingGroup rules. + { + name: "component grove.forceScalingGroup requires Grove", + groveDisabled: true, + deployment: betaDGDForAdmission(func(dgd *nvidiacomv1beta1.DynamoGraphDeployment) { + betaWorkerComponent(dgd).Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + wantWebhookErrs: []string{"spec.components[1].experimental.grove.forceScalingGroup: Forbidden: is currently supported only for Grove-backed DynamoGraphDeployment components"}, + }, + { + name: "v1beta1 grove.forceScalingGroup on a single-node component reaches the webhook", + deployment: betaDGDForAdmission(func(dgd *nvidiacomv1beta1.DynamoGraphDeployment) { + betaWorkerComponent(dgd).Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + }, + { + name: "v1beta1 redundant grove.forceScalingGroup on a multinode component reaches the webhook", + deployment: betaDGDForAdmission(func(dgd *nvidiacomv1beta1.DynamoGraphDeployment) { + worker := betaWorkerComponent(dgd) + worker.Multinode = &nvidiacomv1beta1.MultinodeSpec{NodeCount: 2} + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + }, + // Checkpoint rules. { name: "v1beta1 valid checkpoint configuration reaches the webhook", @@ -1897,6 +1927,63 @@ func TestDynamoGraphDeploymentValidator_Validate(t *testing.T) { "spec.components[1].experimental.failover: Invalid value: null: inter-pod GMS failover cannot be toggled after creation; delete and recreate the DynamoGraphDeployment", }, }, + // Grove forceScalingGroup updates. + { + name: "grove.forceScalingGroup addition is immutable", + oldDeployment: newBetaDGDForValidation(), + deployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + wantWebhookErrs: []string{"spec.components[1].experimental.grove.forceScalingGroup: Invalid value: true: cannot be toggled after creation; delete and recreate the DynamoGraphDeployment to change it"}, + }, + { + name: "grove.forceScalingGroup removal is immutable", + oldDeployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + deployment: newBetaDGDForValidation(), + wantWebhookErrs: []string{"spec.components[1].experimental.grove.forceScalingGroup: Invalid value: null: cannot be toggled after creation; delete and recreate the DynamoGraphDeployment to change it"}, + }, + { + name: "unchanged grove.forceScalingGroup update reaches the webhook", + oldDeployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + deployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + }, + { + name: "explicit false grove.forceScalingGroup addition reaches the webhook", + oldDeployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{} + }), + deployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: false}, + } + }), + }, + { + name: "grove block removal with retained experimental is immutable", + oldDeployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{ + Grove: &nvidiacomv1beta1.GroveSpec{ForceScalingGroup: true}, + } + }), + deployment: betaDGDWithWorker(func(worker *nvidiacomv1beta1.DynamoComponentDeploymentSharedSpec) { + worker.Experimental = &nvidiacomv1beta1.ExperimentalSpec{} + }), + wantWebhookErrs: []string{"spec.components[1].experimental.grove.forceScalingGroup: Invalid value: null: cannot be toggled after creation; delete and recreate the DynamoGraphDeployment to change it"}, + }, { name: "inter-pod failover shadow count is immutable", oldDeployment: alphaDGDForAdmission(func(dgd *nvidiacomv1alpha1.DynamoGraphDeployment) { diff --git a/deploy/operator/internal/webhook/validation/shared_helpers.go b/deploy/operator/internal/webhook/validation/shared_helpers.go index d36302e20de9..d170b7fb8933 100644 --- a/deploy/operator/internal/webhook/validation/shared_helpers.go +++ b/deploy/operator/internal/webhook/validation/shared_helpers.go @@ -189,6 +189,18 @@ func failoverForExperimental(experimental *nvidiacomv1beta1.ExperimentalSpec) *n return experimental.Failover } +func groveForExperimental(experimental *nvidiacomv1beta1.ExperimentalSpec) *nvidiacomv1beta1.GroveSpec { + if experimental == nil { + return nil + } + return experimental.Grove +} + +func forceScalingGroupFor(experimental *nvidiacomv1beta1.ExperimentalSpec) bool { + grove := groveForExperimental(experimental) + return grove != nil && grove.ForceScalingGroup +} + func effectiveGMSMode(mode nvidiacomv1beta1.GPUMemoryServiceMode) nvidiacomv1beta1.GPUMemoryServiceMode { if mode == "" { return nvidiacomv1beta1.GMSModeIntraPod diff --git a/deploy/operator/internal/webhook/validation/shared_test.go b/deploy/operator/internal/webhook/validation/shared_test.go index fb1cf341b8f6..36a89fa91695 100644 --- a/deploy/operator/internal/webhook/validation/shared_test.go +++ b/deploy/operator/internal/webhook/validation/shared_test.go @@ -363,9 +363,10 @@ func TestValidateExperimentalSpecDoesNotExposePodTemplate(t *testing.T) { errs := validation.validateExperimentalSpec( &nvidiacomv1beta1.ExperimentalSpec{GPUMemoryService: gms}, fldPath, - nvidiacomv1beta1.ComponentTypeWorker, - corev1.ResourceRequirements{}, - nil, + experimentalSpecValidationOptions{ + componentType: nvidiacomv1beta1.ComponentTypeWorker, + grovePathway: true, + }, ) assertFieldPaths(t, errs, []string{"spec.components[0].experimental.gpuMemoryService"}) if errs[0].BadValue != "" { diff --git a/deploy/operator/internal/webhook/validation/shared_v1beta1.go b/deploy/operator/internal/webhook/validation/shared_v1beta1.go index 91634f0db068..fbe6419a8b98 100644 --- a/deploy/operator/internal/webhook/validation/shared_v1beta1.go +++ b/deploy/operator/internal/webhook/validation/shared_v1beta1.go @@ -121,9 +121,12 @@ func (v *sharedValidation) validateDynamoComponentDeploymentSharedSpec( allErrs = append(allErrs, v.validateExperimentalSpec( spec.Experimental, fldPath.Child("experimental"), - spec.ComponentType, - dynamo.GetMainContainerResources(spec), - podTemplateContainers(spec.PodTemplate), + experimentalSpecValidationOptions{ + componentType: spec.ComponentType, + resources: dynamo.GetMainContainerResources(spec), + containers: podTemplateContainers(spec.PodTemplate), + grovePathway: grovePathway, + }, )...) } @@ -190,22 +193,27 @@ func (v *sharedValidation) validateTopologyConstraint( return nil } +type experimentalSpecValidationOptions struct { + componentType nvidiacomv1beta1.ComponentType + resources corev1.ResourceRequirements + containers []corev1.Container + grovePathway bool +} + // validateExperimentalSpec validates experimental. experimental and fldPath must not be nil. func (v *sharedValidation) validateExperimentalSpec( experimental *nvidiacomv1beta1.ExperimentalSpec, fldPath *field.Path, - componentType nvidiacomv1beta1.ComponentType, - resources corev1.ResourceRequirements, - containers []corev1.Container, + options experimentalSpecValidationOptions, ) field.ErrorList { allErrs := field.ErrorList{} if experimental.GPUMemoryService != nil { allErrs = append(allErrs, v.validateGPUMemoryServiceSpec( experimental.GPUMemoryService, fldPath.Child("gpuMemoryService"), - componentType, - resources, - containers, + options.componentType, + options.resources, + options.containers, )...) } if experimental.Failover != nil { @@ -213,8 +221,15 @@ func (v *sharedValidation) validateExperimentalSpec( experimental.Failover, fldPath.Child("failover"), experimental.GPUMemoryService, - componentType, - resources, + options.componentType, + options.resources, + )...) + } + if experimental.Grove != nil { + allErrs = append(allErrs, v.validateGroveSpec( + experimental.Grove, + fldPath.Child("grove"), + options.grovePathway, )...) } if experimental.Checkpoint != nil { @@ -344,6 +359,22 @@ func (v *sharedValidation) validateFailoverSpec( return allErrs } +// validateGroveSpec validates grove. grove and fldPath must not be nil. +// grovePathway is supplied by the owning resource. +func (v *sharedValidation) validateGroveSpec( + grove *nvidiacomv1beta1.GroveSpec, + fldPath *field.Path, + grovePathway bool, +) field.ErrorList { + if grove.ForceScalingGroup && !grovePathway { + return field.ErrorList{field.Forbidden( + fldPath.Child("forceScalingGroup"), + "is currently supported only for Grove-backed DynamoGraphDeployment components", + )} + } + return nil +} + // validateComponentCheckpointConfig validates checkpoint. checkpoint and fldPath must not be nil. // gms may be nil because checkpoint validates that sibling relationship. func (v *sharedValidation) validateComponentCheckpointConfig( @@ -459,6 +490,13 @@ func (v *sharedValidation) validateDynamoComponentDeploymentSharedSpecUpdate( fmt.Sprintf("inter-pod GMS failover cannot be toggled after creation; delete and recreate the %s", ownerKind.Kind), )) } + if forceScalingGroupFor(oldComponent.Experimental) { + allErrs = append(allErrs, field.Invalid( + fldPath.Child("experimental", "grove", "forceScalingGroup"), + nil, + fmt.Sprintf("cannot be toggled after creation; delete and recreate the %s to change it", ownerKind.Kind), + )) + } } // Ratchet legacy image absence or an unchanged legacy tuple, but reject a newly invalid tuple. @@ -551,5 +589,41 @@ func (v *sharedValidation) validateExperimentalSpecUpdate( fmt.Sprintf("is immutable for inter-pod GMS failover; delete and recreate the %s to change it", options.ownerKind.Kind), )) } + + oldGrove := groveForExperimental(oldExperimental) + if newExperimental.Grove != nil { + allErrs = append(allErrs, v.validateGroveSpecUpdate( + newExperimental.Grove, + oldGrove, + fldPath.Child("grove"), + options.ownerKind, + )...) + } else if oldGrove != nil && oldGrove.ForceScalingGroup { + allErrs = append(allErrs, field.Invalid( + fldPath.Child("grove", "forceScalingGroup"), + nil, + fmt.Sprintf("cannot be toggled after creation; delete and recreate the %s to change it", options.ownerKind.Kind), + )) + } return allErrs } + +// validateGroveSpecUpdate validates a grove update. newGrove and fldPath must +// not be nil; oldGrove may be nil for an addition. false and omitted both +// mean automatic selection, so only the effective opt-in is immutable. +func (v *sharedValidation) validateGroveSpecUpdate( + newGrove *nvidiacomv1beta1.GroveSpec, + oldGrove *nvidiacomv1beta1.GroveSpec, + fldPath *field.Path, + ownerKind schema.GroupKind, +) field.ErrorList { + oldForced := oldGrove != nil && oldGrove.ForceScalingGroup + if newGrove.ForceScalingGroup == oldForced { + return nil + } + return field.ErrorList{field.Invalid( + fldPath.Child("forceScalingGroup"), + newGrove.ForceScalingGroup, + fmt.Sprintf("cannot be toggled after creation; delete and recreate the %s to change it", ownerKind.Kind), + )} +} 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 6aba62617c59..60f11b7cf01e 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 @@ -2127,7 +2127,7 @@ _Appears in:_ | `globalDynamoNamespace` _boolean_ | globalDynamoNamespace places the component in the global Dynamo
namespace rather than the per-deployment namespace derived from the
DGD name. | | Optional: \{\}
| | `podTemplate` _[PodTemplateSpec](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#podtemplatespec-v1-core)_ | podTemplate defines the component's Pod configuration. New components must
include a container named "main" with a non-empty image. Existing components
created without a podTemplate may remain unchanged. The operator merges
defaults into the main container.
For DGD components whose main image tag is not a Dynamo semantic version,
set runtimeVersionOverride explicitly.
All other containers are user-managed sidecars and must specify their
required fields, including image. | | Optional: \{\}
| | `replicas` _integer_ | replicas is the desired number of Pods for this component. When
`scalingAdapter` is set on this component, this field is managed by
the DynamoGraphDeploymentScalingAdapter and should not be modified
directly. | | Minimum: 0
Optional: \{\}
| -| `minAvailable` _integer_ | minAvailable maps to Grove PodClique minAvailable for single-node and
Grove PodCliqueScalingGroup minAvailable for multi-node components.
This field determines 1) the minimum number of replicas guaranteed to be
gang-scheduled, and 2) when violating minAvailable replicas triggers gang
termination.
For Grove-backed DynamoGraphDeployment components, minAvailable defaults to
1 when omitted and is immutable after creation. Positive replica counts must
be greater than or equal to minAvailable. Replicas may be scaled to 0 as a
special scale-to-zero state; minAvailable remains configured but is not
enforced again until replicas is scaled back to a positive value.
For non-Grove deployments, setting this field will result in a validation error. | | Minimum: 1
Optional: \{\}
| +| `minAvailable` _integer_ | minAvailable maps to Grove PodCliqueScalingGroup minAvailable for
components rendered as a scaling group (multi-node, inter-pod GMS, or
`experimental.grove.forceScalingGroup`; see `UsesPCSG`) and to Grove
PodClique minAvailable for all other single-node components.
This field determines 1) the minimum number of replicas guaranteed to be
gang-scheduled, and 2) when violating minAvailable replicas triggers gang
termination.
For Grove-backed DynamoGraphDeployment components, minAvailable defaults to
1 when omitted and is immutable after creation. Positive replica counts must
be greater than or equal to minAvailable. Replicas may be scaled to 0 as a
special scale-to-zero state; minAvailable remains configured but is not
enforced again until replicas is scaled back to a positive value.
For non-Grove deployments, setting this field will result in a validation error. | | Minimum: 1
Optional: \{\}
| | `multinode` _[MultinodeSpec](#multinodespec)_ | multinode configures multinode components. | | Optional: \{\}
| | `sharedMemorySize` _[Quantity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#quantity-resource-api)_ | sharedMemorySize controls the size of the tmpfs mounted at `/dev/shm`.
`nil` selects the operator default (8Gi), a positive quantity sets a
custom size, and `"0"` disables the shared-memory volume entirely.
Simpler replacement for v1alpha1's `SharedMemorySpec` struct with its
`disabled bool` + `size Quantity` pattern. | | Optional: \{\}
| | `modelRef` _[ModelReference](#modelreference)_ | modelRef references a model served by this component. When specified,
a headless service is created for endpoint discovery. | | Optional: \{\}
| @@ -2159,7 +2159,7 @@ _Appears in:_ | `globalDynamoNamespace` _boolean_ | globalDynamoNamespace places the component in the global Dynamo
namespace rather than the per-deployment namespace derived from the
DGD name. | | Optional: \{\}
| | `podTemplate` _[PodTemplateSpec](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#podtemplatespec-v1-core)_ | podTemplate defines the component's Pod configuration. New components must
include a container named "main" with a non-empty image. Existing components
created without a podTemplate may remain unchanged. The operator merges
defaults into the main container.
For DGD components whose main image tag is not a Dynamo semantic version,
set runtimeVersionOverride explicitly.
All other containers are user-managed sidecars and must specify their
required fields, including image. | | Optional: \{\}
| | `replicas` _integer_ | replicas is the desired number of Pods for this component. When
`scalingAdapter` is set on this component, this field is managed by
the DynamoGraphDeploymentScalingAdapter and should not be modified
directly. | | Minimum: 0
Optional: \{\}
| -| `minAvailable` _integer_ | minAvailable maps to Grove PodClique minAvailable for single-node and
Grove PodCliqueScalingGroup minAvailable for multi-node components.
This field determines 1) the minimum number of replicas guaranteed to be
gang-scheduled, and 2) when violating minAvailable replicas triggers gang
termination.
For Grove-backed DynamoGraphDeployment components, minAvailable defaults to
1 when omitted and is immutable after creation. Positive replica counts must
be greater than or equal to minAvailable. Replicas may be scaled to 0 as a
special scale-to-zero state; minAvailable remains configured but is not
enforced again until replicas is scaled back to a positive value.
For non-Grove deployments, setting this field will result in a validation error. | | Minimum: 1
Optional: \{\}
| +| `minAvailable` _integer_ | minAvailable maps to Grove PodCliqueScalingGroup minAvailable for
components rendered as a scaling group (multi-node, inter-pod GMS, or
`experimental.grove.forceScalingGroup`; see `UsesPCSG`) and to Grove
PodClique minAvailable for all other single-node components.
This field determines 1) the minimum number of replicas guaranteed to be
gang-scheduled, and 2) when violating minAvailable replicas triggers gang
termination.
For Grove-backed DynamoGraphDeployment components, minAvailable defaults to
1 when omitted and is immutable after creation. Positive replica counts must
be greater than or equal to minAvailable. Replicas may be scaled to 0 as a
special scale-to-zero state; minAvailable remains configured but is not
enforced again until replicas is scaled back to a positive value.
For non-Grove deployments, setting this field will result in a validation error. | | Minimum: 1
Optional: \{\}
| | `multinode` _[MultinodeSpec](#multinodespec)_ | multinode configures multinode components. | | Optional: \{\}
| | `sharedMemorySize` _[Quantity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#quantity-resource-api)_ | sharedMemorySize controls the size of the tmpfs mounted at `/dev/shm`.
`nil` selects the operator default (8Gi), a positive quantity sets a
custom size, and `"0"` disables the shared-memory volume entirely.
Simpler replacement for v1alpha1's `SharedMemorySpec` struct with its
`disabled bool` + `size Quantity` pattern. | | Optional: \{\}
| | `modelRef` _[ModelReference](#modelreference)_ | modelRef references a model served by this component. When specified,
a headless service is created for endpoint discovery. | | Optional: \{\}
| @@ -2469,6 +2469,7 @@ _Appears in:_ | --- | --- | --- | --- | | `gpuMemoryService` _[GPUMemoryServiceSpec](#gpumemoryservicespec)_ | gpuMemoryService configures the GPU Memory Service (GMS). When set, GPU
access for GMS clients is managed via DRA. | | Optional: \{\}
| | `failover` _[FailoverSpec](#failoverspec)_ | failover configures active-passive GPU failover for this component.
Requires `gpuMemoryService` to also be set, and `failover.mode` must
match `gpuMemoryService.mode` (enforced by the validation webhook). | | Optional: \{\}
| +| `grove` _[GroveSpec](#grovespec)_ | grove groups Grove-specific rendering options. | | Optional: \{\}
| | `checkpoint` _[ComponentCheckpointConfig](#componentcheckpointconfig)_ | checkpoint configures container-image snapshotting and restore for
this component. Set `checkpoint.enabled: true` to opt in. Without
checkpointRef, the DGD controller creates a DGD-scoped DynamoCheckpoint
CR and later restores pods in the same DGD generation from that
checkpoint. With checkpointRef, the DGD restores from that existing
checkpoint instead. The user-facing shape of this field is still settling,
which is why it lives under `experimental` in v1beta1 instead of at the
top level. | | Optional: \{\}
| @@ -2602,6 +2603,22 @@ _Appears in:_ | `mi300` | | +#### GroveSpec + + + +GroveSpec groups experimental Grove-specific rendering options. + + + +_Appears in:_ +- [ExperimentalSpec](#experimentalspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `forceScalingGroup` _boolean_ | forceScalingGroup opts a single-node component into rendering as a
PodCliqueScalingGroup with one single-pod PodClique per replica.
Scaling changes the scaling-group replica count. The first
`minAvailable` replicas join the deployment's base PodGang together
with its other base workloads; each replica beyond `minAvailable`
gets its own PodGang, gang-scheduled separately from the rest of the
deployment. `false` or omitted means automatic selection (multi-node
and inter-pod GMS components use a scaling group, other single-node
components a standalone PodClique), not "force PodClique".
Immutable after creation. | | Optional: \{\}
| + + #### HardwareSpec