@@ -63,6 +63,14 @@ const (
6363 // ncclTrainingRuntimeName is the name of the TrainingRuntime resource.
6464 // Must stay in sync with runtime.yaml.
6565 ncclTrainingRuntimeName = "nccl-all-reduce-runtime"
66+
67+ // ncclWorkloadNamespacePrefix is the base for the per-run benchmark
68+ // namespace (see runNCCLTrainJob). Isolating each run in its own
69+ // namespace, the same pattern inferenceWorkloadNamespacePrefix uses,
70+ // means the fixed resource names below never collide across concurrent
71+ // or crashed runs: uniqueness only has to hold within a namespace, and
72+ // cleanup is a single namespace delete instead of per-resource tracking.
73+ ncclWorkloadNamespacePrefix = "aicr-nccl-perf"
6674)
6775
6876// skipMsg* are the constraint-result strings returned when the NCCL check cannot
@@ -478,6 +486,26 @@ func runNCCLTrainJob(ctx *validators.Context, gpuConfig *gpuConfiguration,
478486
479487 dynamicClient := ctx .DynamicClient
480488
489+ // Isolate this run in its own namespace, the same pattern
490+ // inferenceWorkloadConfig uses (see deriveRunID/ensureNamespace): every
491+ // fixed resource name below only has to be unique within it, so two
492+ // concurrent (or one crashed, one retried) aicr validate runs can never
493+ // collide, adopt, or delete each other's resources — no lock required.
494+ gpuConfig .Namespace = fmt .Sprintf ("%s-%s" , ncclWorkloadNamespacePrefix , deriveRunID ())
495+ if err = ensureNamespace (ctx , gpuConfig .Namespace ); err != nil {
496+ return "" , aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "failed to create NCCL benchmark namespace" , err )
497+ }
498+
499+ // Clean up the per-run namespace (and everything created in it) on every
500+ // exit path from here on, including a failed Trainer install below.
501+ // NotFound-tolerant, so running it after an early/partial-apply failure is
502+ // safe. A cleanup failure only overrides a nil benchErr — see
503+ // foldCleanupError — so it never masks a real benchmark failure.
504+ defer func () {
505+ err = foldCleanupError (err , cleanupNCCLResources (ctx .Clientset , gpuConfig .Namespace ),
506+ "NCCL benchmark succeeded but NCCL resource cleanup failed" )
507+ }()
508+
481509 // Ensure a usable Kubeflow Trainer. Whether an incomplete installation is a
482510 // failure or something to install over is decided by the recipe, not by what
483511 // happens to be on the cluster: a recipe that ships the component must have a
@@ -492,21 +520,11 @@ func runNCCLTrainJob(ctx *validators.Context, gpuConfig *gpuConfiguration,
492520 }
493521 if len (installedResources ) > 0 {
494522 defer func () {
495- err = foldCleanupError (err , deleteTrainer (dynamicClient , installedResources ))
523+ err = foldCleanupError (err , deleteTrainer (dynamicClient , installedResources ),
524+ "NCCL benchmark succeeded but Kubeflow Trainer cleanup failed" )
496525 }()
497526 }
498527
499- // Clean up NCCL resources on every exit path. Registered after the trainer
500- // install block but before the apply: defers run LIFO, so this runs *before*
501- // the conditional deleteTrainer above — the NCCL TrainJob/TrainingRuntime CRs
502- // are deleted while their CRDs still exist, rather than relying on CRD-delete
503- // cascade GC. Registering it before applyNCCLResources still guarantees a
504- // partial-apply failure (e.g. the RoCE claim is created, then the runtime or
505- // TrainJob apply fails) doesn't leak nccl-roce-rct into the persistent, reused
506- // validation namespace. cleanupNCCLResources is NotFound-tolerant for every
507- // resource it deletes, so running it after an early failure is safe.
508- defer cleanupNCCLResources (dynamicClient , gpuConfig .Namespace )
509-
510528 // Apply runtime and trainjob resources. Propagate an inner code rather than
511529 // forcing ErrCodeInternal — a recipe-supplied runtime that fails to render is
512530 // an ErrCodeInvalidRequest (recipe-authoring error), not an internal fault.
@@ -516,7 +534,7 @@ func runNCCLTrainJob(ctx *validators.Context, gpuConfig *gpuConfiguration,
516534
517535 podHelper := & helper.PodLifecycle {
518536 ClientSet : ctx .Clientset ,
519- Namespace : ctx .Namespace ,
537+ Namespace : gpuConfig .Namespace ,
520538 }
521539
522540 // Wait for launcher pod and get logs.
@@ -533,8 +551,11 @@ type gpuConfiguration struct {
533551 WorkerCount int
534552 GPUCountPerNode int
535553 TotalGPUCount int
536- Namespace string
537- Nodes []v1.Node
554+ // Namespace is the per-run benchmark namespace; unset by determineGPUConfig
555+ // and filled in by runNCCLTrainJob once it derives one (see
556+ // ncclWorkloadNamespacePrefix).
557+ Namespace string
558+ Nodes []v1.Node
538559}
539560
540561// parseThreshold extracts the numeric threshold value from a constraint value.
@@ -754,7 +775,6 @@ func determineGPUConfig(ctx *validators.Context, service recipe.CriteriaServiceT
754775 WorkerCount : len (targetNodes ),
755776 GPUCountPerNode : gpuCountPerNode ,
756777 TotalGPUCount : totalGPUs ,
757- Namespace : ctx .Namespace ,
758778 Nodes : targetNodes ,
759779 }, nil
760780}
@@ -929,18 +949,18 @@ func applyNCCLResources(ctx *validators.Context, dynamicClient dynamic.Interface
929949 if err != nil {
930950 return err
931951 }
932- if err : = applyNCCLWorkerScheduling (runtimeObj , effectiveNodeSelector , effectiveTolerations ); err != nil {
952+ if err = applyNCCLWorkerScheduling (runtimeObj , effectiveNodeSelector , effectiveTolerations ); err != nil {
933953 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "failed to apply NCCL worker scheduling" , err )
934954 }
935- if err : = createUnstructured (ctx .Ctx , dynamicClient , trainingRuntimeGVR , config .Namespace , runtimeObj ); err != nil {
955+ if err = createUnstructured (ctx .Ctx , dynamicClient , trainingRuntimeGVR , config .Namespace , runtimeObj ); err != nil {
936956 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "failed to apply training runtime" , err )
937957 }
938958 slog .Info ("Applied TrainingRuntime" , "service" , service )
939959
940960 // Wait for the runtime to be visible to the Trainer admission webhook.
941961 // The webhook validates that the referenced runtime exists before allowing
942962 // TrainJob creation; without this wait we hit a race condition.
943- if err : = waitForTrainingRuntime (ctx .Ctx , dynamicClient , config .Namespace ); err != nil {
963+ if err = waitForTrainingRuntime (ctx .Ctx , dynamicClient , config .Namespace ); err != nil {
944964 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "TrainingRuntime not ready" , err )
945965 }
946966
@@ -953,10 +973,10 @@ func applyNCCLResources(ctx *validators.Context, dynamicClient dynamic.Interface
953973 // runtime: IMEX/ComputeDomain wiring is part of the fabric contract the
954974 // runtime owns, so it must declare any ComputeDomain/ResourceClaim it needs.
955975 if customRuntime == "" && variant == variantNVLS {
956- if err : = applyNCCLComputeDomain (ctx .Ctx , dynamicClient , config .Namespace ); err != nil {
976+ if err = applyNCCLComputeDomain (ctx .Ctx , dynamicClient , config .Namespace ); err != nil {
957977 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "failed to apply ComputeDomain" , err )
958978 }
959- if err : = waitForIMEXClaimTemplate (ctx .Ctx , dynamicClient , config .Namespace ); err != nil {
979+ if err = waitForIMEXClaimTemplate (ctx .Ctx , dynamicClient , config .Namespace ); err != nil {
960980 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "IMEX ResourceClaimTemplate not ready" , err )
961981 }
962982 }
@@ -968,7 +988,7 @@ func applyNCCLResources(ctx *validators.Context, dynamicClient dynamic.Interface
968988 // rejected with "TrainingRuntime not found". applyTrainJobWithRetry retries
969989 // on exactly that denial until the webhook cache catches up.
970990 trainjobPath := filepath .Join ("testdata" , "trainjob.yaml" )
971- if err : = applyTrainJobWithRetry (ctx .Ctx , dynamicClient , config .Namespace , trainjobPath , templateData ); err != nil {
991+ if err = applyTrainJobWithRetry (ctx .Ctx , dynamicClient , config .Namespace , trainjobPath , templateData ); err != nil {
972992 return err
973993 }
974994 slog .Info ("Applied TrainJob" )
@@ -1107,7 +1127,10 @@ func applyNCCLComputeDomain(ctx context.Context, dynamicClient dynamic.Interface
11071127
11081128 // AlreadyExists: fetch the current resourceVersion and Update in place.
11091129 // Required because Update rejects an empty resourceVersion to prevent
1110- // lost updates.
1130+ // lost updates. Adopting an existing ComputeDomain here is intentional:
1131+ // it's how a stale one left by a prior run under this same per-run
1132+ // namespace (e.g. a retry reusing AICR_RUN_ID after a hard kill before
1133+ // cleanup ran) gets reclaimed instead of failing with AlreadyExists.
11111134 existing , err := client .Get (applyCtx , ncclComputeDomainName , metav1.GetOptions {})
11121135 if err != nil {
11131136 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "failed to get existing ComputeDomain" , err )
@@ -1300,12 +1323,12 @@ func renderYAMLTemplate(content string, data map[string]string) (*unstructured.U
13001323 return obj , nil
13011324}
13021325
1303- // createUnstructured creates a namespaced resource from an unstructured object with a timeout.
1326+ // createUnstructured creates a namespaced resource from an unstructured object
1327+ // with a timeout.
13041328func createUnstructured (ctx context.Context , dynamicClient dynamic.Interface , gvr schema.GroupVersionResource , namespace string , obj * unstructured.Unstructured ) error {
13051329 applyCtx , cancel := context .WithTimeout (ctx , defaults .DiagnosticTimeout )
13061330 defer cancel ()
1307- _ , err := dynamicClient .Resource (gvr ).Namespace (namespace ).Create (applyCtx , obj , metav1.CreateOptions {})
1308- if err != nil {
1331+ if _ , err := dynamicClient .Resource (gvr ).Namespace (namespace ).Create (applyCtx , obj , metav1.CreateOptions {}); err != nil {
13091332 return aicrErrors .Wrap (aicrErrors .ErrCodeInternal , "failed to create resource" , err )
13101333 }
13111334 return nil
@@ -1505,7 +1528,7 @@ func waitForLauncherPodAndGetLogs(ctx *validators.Context, podHelper *helper.Pod
15051528 launcherPod , err := waitForPodByLabelSelector (
15061529 ctx .Ctx ,
15071530 ctx .Clientset ,
1508- ctx .Namespace ,
1531+ podHelper .Namespace ,
15091532 fmt .Sprintf ("jobset.sigs.k8s.io/jobset-name=%s,jobset.sigs.k8s.io/replicatedjob-name=launcher" , ncclTrainJobName ),
15101533 defaults .NCCLLauncherPodTimeout ,
15111534 )
@@ -1557,14 +1580,14 @@ func waitForLauncherPodAndGetLogs(ctx *validators.Context, podHelper *helper.Pod
15571580 // the pod object and survives the container GC that GetPodLogs loses to.
15581581 // Either way the fetchNote reason is preserved in the payload.
15591582 if launcherLogs == "" {
1560- if term := launcherTerminationTail (ctx .Ctx , ctx .Clientset , ctx .Namespace , launcherPod .Name ); term != "" {
1583+ if term := launcherTerminationTail (ctx .Ctx , ctx .Clientset , podHelper .Namespace , launcherPod .Name ); term != "" {
15611584 launcherLogs = fmt .Sprintf ("<%s; container termination-message tail follows>\n %s" ,
15621585 fetchNote , tailLines (term , maxDiagLogLines ))
15631586 } else {
15641587 launcherLogs = fmt .Sprintf ("<%s; no termination message captured>" , fetchNote )
15651588 }
15661589 }
1567- workerDiag := collectNCCLWorkerDiagnostics (ctx .Ctx , ctx .Clientset , ctx .Namespace )
1590+ workerDiag := collectNCCLWorkerDiagnostics (ctx .Ctx , ctx .Clientset , podHelper .Namespace )
15681591
15691592 // Surface the diagnostics via slog, not just the return value: every
15701593 // caller on this error path (runNCCLTrainJob, validateNcclAllReduceBw,
@@ -1600,7 +1623,7 @@ func waitForLauncherPodAndGetLogs(ctx *validators.Context, podHelper *helper.Pod
16001623 // rotation-proof source of truth while the streamed log remains available for
16011624 // transport verification and diagnostics. Empty for launchers that don't write
16021625 // results there (other platforms), leaving behavior unchanged.
1603- term := launcherTerminationTail (ctx .Ctx , ctx .Clientset , ctx .Namespace , launcherPod .Name )
1626+ term := launcherTerminationTail (ctx .Ctx , ctx .Clientset , podHelper .Namespace , launcherPod .Name )
16041627 if term != "" {
16051628 slog .Info ("Appending launcher termination message (rotation-proof results)" , "termBytes" , len (term ))
16061629 }
@@ -2075,66 +2098,53 @@ func verifyTransportFromLogs(logs string, variant ncclVariant) error {
20752098 }
20762099}
20772100
2078- // cleanupNCCLResources removes the trainjob, runtime, and (if present) the
2079- // ComputeDomain CR using the dynamic client. Deleting the ComputeDomain
2080- // cascades to its auto-generated ResourceClaimTemplate via the DRA driver;
2081- // NotFound on the ComputeDomain is expected for the default/NET variants
2082- // and is logged at debug rather than error.
2083- func cleanupNCCLResources (dynamicClient dynamic.Interface , namespace string ) {
2084- slog .Info ("Cleaning up NCCL test resources..." )
2101+ // cleanupNCCLResources deletes the per-run benchmark namespace, cascading
2102+ // away the trainjob, runtime, and (if present) the ComputeDomain and RoCE
2103+ // ResourceClaimTemplate CRs this run created in it — mirroring
2104+ // cleanupInferenceWorkload's pattern for the sibling inference-perf check.
2105+ // Since runNCCLTrainJob gives every run its own namespace (see
2106+ // ncclWorkloadNamespacePrefix), there is no shared state to pin deletes
2107+ // against: nothing else ever lives in this namespace.
2108+ //
2109+ // Namespaces().Delete only starts asynchronous deletion — it returns as soon
2110+ // as the delete is accepted, not once the namespace (and the
2111+ // ComputeDomain/ResourceClaimTemplate/TrainJob finalizers cascading through
2112+ // it) is actually gone. Waiting here for the deletion to finish, via the
2113+ // same waitForNamespaceGone helper ensureNamespace already uses on the
2114+ // create side for exactly this reason (see its doc comment), means a
2115+ // successful benchmark can't report clean teardown while resources are
2116+ // still leaking.
2117+ //
2118+ // Unlike cleanupInferenceWorkload, a delete failure here is returned rather
2119+ // than only logged, so foldCleanupError can still fail an otherwise-passing
2120+ // check on it. NotFound is tolerated (nothing to clean up).
2121+ func cleanupNCCLResources (clientset kubernetes.Interface , namespace string ) error {
2122+ slog .Info ("Cleaning up NCCL test resources..." , "namespace" , namespace )
20852123
2086- cleanupCtx , cancel := context .WithTimeout (context .Background (), defaults .DiagnosticTimeout )
2124+ deleteCtx , cancel := context .WithTimeout (context .Background (), defaults .K8sCleanupTimeout )
20872125 defer cancel ()
20882126
2089- // Delete trainjob. NotFound is expected and logged at debug: this runs as a
2090- // deferred cleanup registered before the apply, so an early/partial-apply
2091- // failure (or the install-trainer path, where deleteTrainer may already have
2092- // cascade-removed the CRs) legitimately leaves no TrainJob to delete.
2093- err := dynamicClient .Resource (trainJobGVR ).Namespace (namespace ).Delete (cleanupCtx , ncclTrainJobName , metav1.DeleteOptions {})
2094- switch {
2095- case err == nil :
2096- slog .Info ("Deleted TrainJob" )
2097- case apierrors .IsNotFound (err ):
2098- slog .Debug ("TrainJob not present, skipping" , "name" , ncclTrainJobName )
2099- default :
2100- slog .Warn ("failed to delete TrainJob" , "error" , err )
2101- }
2102-
2103- // Delete runtime. NotFound is expected and logged at debug (see TrainJob above).
2104- err = dynamicClient .Resource (trainingRuntimeGVR ).Namespace (namespace ).Delete (cleanupCtx , ncclTrainingRuntimeName , metav1.DeleteOptions {})
2105- switch {
2106- case err == nil :
2107- slog .Info ("Deleted TrainingRuntime" )
2108- case apierrors .IsNotFound (err ):
2109- slog .Debug ("TrainingRuntime not present, skipping" , "name" , ncclTrainingRuntimeName )
2110- default :
2111- slog .Warn ("failed to delete TrainingRuntime" , "error" , err )
2127+ nsClient := clientset .CoreV1 ().Namespaces ()
2128+ err := nsClient .Delete (deleteCtx , namespace , metav1.DeleteOptions {})
2129+ if err != nil {
2130+ if apierrors .IsNotFound (err ) {
2131+ slog .Info ("NCCL benchmark namespace already gone" , "namespace" , namespace )
2132+ return nil
2133+ }
2134+ return aicrErrors .Wrap (aicrErrors .ErrCodeInternal ,
2135+ fmt .Sprintf ("failed to delete NCCL benchmark namespace %q" , namespace ), err )
21122136 }
21132137
2114- // Delete ComputeDomain if this was the NVLS variant. NotFound is the
2115- // expected path for default/NET and is ignored here; other errors bubble
2116- // up as a warning because the RCT and IMEX daemons otherwise leak.
2117- err = dynamicClient .Resource (computeDomainGVR ).Namespace (namespace ).Delete (cleanupCtx , ncclComputeDomainName , metav1.DeleteOptions {})
2118- switch {
2119- case err == nil :
2120- slog .Info ("Deleted ComputeDomain" )
2121- case apierrors .IsNotFound (err ):
2122- slog .Debug ("ComputeDomain not present (non-NVLS variant), skipping" , "name" , ncclComputeDomainName )
2123- default :
2124- slog .Warn ("failed to delete ComputeDomain" , "error" , err , "name" , ncclComputeDomainName )
2138+ // Same bound as ensureNamespace's wait on the create side (see
2139+ // defaults.InferenceNamespaceTerminationWait doc comment) — this cascade
2140+ // is the same finalizer chain, just observed from the delete side.
2141+ waitCtx , waitCancel := context .WithTimeout (context .Background (), defaults .InferenceNamespaceTerminationWait )
2142+ defer waitCancel ()
2143+ if err := waitForNamespaceGone (waitCtx , nsClient , namespace ); err != nil {
2144+ return aicrErrors .Wrap (aicrErrors .ErrCodeInternal ,
2145+ fmt .Sprintf ("NCCL benchmark namespace %q did not finish terminating" , namespace ), err )
21252146 }
21262147
2127- // Delete the RoCE ResourceClaimTemplate (RoCE NET variant only). The
2128- // validator namespace is persistent and reused across runs, so leaving it
2129- // behind makes the next RoCE run fail with AlreadyExists when
2130- // applyNCCLResources re-creates it. NotFound is expected for EFA/NVLS runs.
2131- err = dynamicClient .Resource (resourceClaimTemplateGVR ).Namespace (namespace ).Delete (cleanupCtx , ncclRoceClaimName , metav1.DeleteOptions {})
2132- switch {
2133- case err == nil :
2134- slog .Info ("Deleted RoCE ResourceClaimTemplate" , "name" , ncclRoceClaimName )
2135- case apierrors .IsNotFound (err ):
2136- slog .Debug ("RoCE ResourceClaimTemplate not present (non-RoCE variant), skipping" , "name" , ncclRoceClaimName )
2137- default :
2138- slog .Warn ("failed to delete RoCE ResourceClaimTemplate" , "error" , err , "name" , ncclRoceClaimName )
2139- }
2148+ slog .Info ("Deleted NCCL benchmark namespace" , "namespace" , namespace )
2149+ return nil
21402150}
0 commit comments