Skip to content

Commit 64a5680

Browse files
committed
fix(validator): isolate each NCCL benchmark run in its own namespace
The NCCL all-reduce TrainJob/TrainingRuntime/ComputeDomain/RoCE-claim resources are applied by fixed name in the shared, persistent aicr-validation namespace, so two concurrent `aicr validate` runs (or a crashed run and its retry) could delete or adopt each other's resources. This affects every NCCL leaf in the catalog (EFA, TCPXO, RoCE, NVLS across A100/H100/B200/GB200/GB300), not just one accelerator or fabric. Give each run its own namespace instead, mirroring the pattern inference-perf already uses (deriveRunID/ensureNamespace): every fixed resource name only has to be unique within its own namespace, so concurrent or crashed runs can never collide, adopt, or delete each other's resources. Cleanup deletes the namespace and waits for it to fully terminate (cascading GC for anything created in it) before returning, and now returns a real cleanup failure instead of only logging it, so a genuine cleanup failure fails the check. Signed-off-by: Mike Cook <micook@nvidia.com>
1 parent 751cf38 commit 64a5680

5 files changed

Lines changed: 274 additions & 127 deletions

File tree

validators/performance/nccl_all_reduce_bw_constraint.go

Lines changed: 95 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
13041328
func 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
}

validators/performance/nccl_all_reduce_bw_constraint_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ import (
2121
"strings"
2222
"testing"
2323

24+
"github.com/NVIDIA/aicr/validators"
2425
corev1 "k8s.io/api/core/v1"
26+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2527
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2628
"k8s.io/apimachinery/pkg/runtime"
2729
"k8s.io/client-go/kubernetes/fake"
30+
k8stesting "k8s.io/client-go/testing"
2831
)
2932

3033
func TestEmitDiagnosticBlock(t *testing.T) {
@@ -243,3 +246,35 @@ func TestCollectNCCLWorkerDiagnostics(t *testing.T) {
243246
})
244247
}
245248
}
249+
250+
// TestRunNCCLTrainJob_TrainerInstallFailureCleansUpNamespace is the regression
251+
// guard for the namespace-cleanup defer's registration point: it must be
252+
// registered right after ensureNamespace succeeds, not after
253+
// ensureTrainerInstalled, or a Trainer-install failure returns before the
254+
// defer is ever registered and leaks the per-run namespace forever.
255+
func TestRunNCCLTrainJob_TrainerInstallFailureCleansUpNamespace(t *testing.T) {
256+
dynamicClient := newTrainerFakeClient(completeTrainerInstall()...)
257+
dynamicClient.PrependReactor("get", resourceCRDs, func(k8stesting.Action) (bool, runtime.Object, error) {
258+
return true, nil, apierrors.NewServiceUnavailable("apiserver is down")
259+
})
260+
261+
clientset := fake.NewClientset()
262+
vctx := &validators.Context{
263+
Ctx: context.Background(),
264+
Clientset: clientset,
265+
DynamicClient: dynamicClient,
266+
}
267+
gpuConfig := &gpuConfiguration{WorkerCount: 2, GPUCountPerNode: 4, TotalGPUCount: 8}
268+
269+
_, err := runNCCLTrainJob(vctx, gpuConfig, "", "", variantDefault, fabricEFA, "")
270+
if err == nil {
271+
t.Fatal("expected an error from the failed Trainer install probe, got nil")
272+
}
273+
if gpuConfig.Namespace == "" {
274+
t.Fatal("gpuConfig.Namespace was never set; ensureNamespace apparently wasn't reached")
275+
}
276+
277+
if _, getErr := clientset.CoreV1().Namespaces().Get(context.Background(), gpuConfig.Namespace, metav1.GetOptions{}); !apierrors.IsNotFound(getErr) {
278+
t.Errorf("namespace %q was not cleaned up after Trainer install failure: get err = %v", gpuConfig.Namespace, getErr)
279+
}
280+
}

0 commit comments

Comments
 (0)