Skip to content

Commit 23e97a7

Browse files
authored
feat(validator): AKS H100 NCCL all-reduce performance validation (NVIDIA#1695)
Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent 37ba978 commit 23e97a7

10 files changed

Lines changed: 587 additions & 31 deletions

File tree

docs/integrator/aks-gpu-setup.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,24 @@ time. See
173173
`Standard_ND96isr_H100_v5` is the 8-GPU ND H100 v5 SKU. The AKS Dynamo
174174
inference throughput gate (`inference-throughput`) is a fixed absolute
175175
**full-node** floor calibrated on an 8-GPU H100 node, so this SKU is the
176-
supported happy path for that gate. Smaller NCads H100 SKUs
177-
(`Standard_NC80adis_H100_v5` = 2 GPUs, `Standard_NC40ads_H100_v5` = 1 GPU) run
178-
fine for deployment but will false-fail the throughput floor; gate on
179-
`inference-ttft-p99` only on those until the per-GPU normalization in
176+
supported happy path for that gate. The same applies to the AKS H100 training
177+
NCCL gate (`nccl-all-reduce-bw >= 150`): its floor is calibrated on full
178+
ND96isr nodes using the Network Operator's RDMA shared device pool
179+
(`rdma/hca_shared_devices_a`) over the SKU's multi-HCA InfiniBand fabric.
180+
Smaller NCads H100 SKUs (`Standard_NC80adis_H100_v5` = 2 GPUs,
181+
`Standard_NC40ads_H100_v5` = 1 GPU) run fine for deployment but will
182+
false-fail both full-node floors — they lack the GPU count and IB fabric the
183+
calibrations assume; gate on `inference-ttft-p99` only on those until the
184+
per-GPU normalization in
180185
[#1254](https://github.com/NVIDIA/aicr/issues/1254) lands.
181186

187+
The NCCL gate's benchmark pods pull the `nccl-tests` image from
188+
`public.ecr.aws` (AWS's public registry) — a cross-cloud pull when running on
189+
Azure. Private or egress-restricted AKS clusters must allow registry egress to
190+
`public.ecr.aws` or mirror the image into an Azure-reachable registry (e.g.
191+
ACR) before running the performance phase; otherwise the benchmark workers
192+
fail at image pull and the check fails without measuring anything.
193+
182194
### Alternative: Use the AKS Driver-Only Profile
183195

184196
If you prefer AKS to install the driver (e.g., for driver version pinning by

docs/user/validation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ ones) that match the target fabric:
4747

4848
| Check | Transport | When it's selected |
4949
|---|---|---|
50-
| `nccl-all-reduce-bw` | Auto-detect (whatever NCCL picks) | H100/H200 on EKS, H100 on GKE, and B200/GB200 on self-managed clusters (`service=any`). Preserves the pre-variant behavior. |
50+
| `nccl-all-reduce-bw` | Auto-detect (whatever NCCL picks) | H100/H200 on EKS, H100 on GKE, H100 on AKS (ND-series InfiniBand — NCCL's built-in IB/verbs transport over the `rdma/hca_shared_devices_a` shared device pool), and B200/GB200 on self-managed clusters (`service=any`). Preserves the pre-variant behavior. |
5151
| `nccl-all-reduce-bw-net` | NET (EFA on EKS by default; ConnectX RoCE via `AICR_NCCL_FABRIC=roce`) | GB200 + EKS. Asserts EFA actually carried traffic — catches silent fallback to Socket when the NVIDIA driver is missing `NVreg_GrdmaPciTopoCheckOverride=1`. |
5252
| `nccl-all-reduce-bw-nvls` | NVLS (MNNVL across an NVL72 IMEX domain) | GB200 + EKS, and GB200 + OKE. Asserts the NVLS communicator actually initialized — catches silent fallback to EFA (EKS) or Socket (OKE) when the IMEX domain is misconfigured. |
5353

examples/recipes/aks-training.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ validation:
168168
- expected-resources
169169
- gpu-operator-version
170170
- check-nvidia-smi
171+
performance:
172+
checks:
173+
- nccl-all-reduce-bw
174+
constraints:
175+
# Provisional floor pending calibration on an ND96isr_H100_v5 testbed —
176+
# see recipes/overlays/h100-aks-training.yaml.
177+
- name: nccl-all-reduce-bw
178+
value: '>= 150'
171179
conformance:
172180
checks:
173181
- platform-health

recipes/overlays/a100-aks-training.yaml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ spec:
6363
# here.
6464
#
6565
# Performance gating is intentionally omitted until an empirical A100-on-Azure
66-
# NCCL baseline is established. AKS has no validator NCCL runtime template or
67-
# scheduling path (it is not in the support table), so even the H100 AKS
68-
# training overlay leaves the performance phase undeclared rather than gate on
69-
# an unmeasured floor (see issue #1337). A100 on Azure is likewise unmeasured
70-
# and would need its own fabric-aware baseline (#1256), so carrying any floor
71-
# here would only false-fail healthy runs.
66+
# NCCL baseline is established. The validator's AKS NCCL runtime path covers
67+
# H100 only (supportedNCCLCombinations in
68+
# validators/performance/nccl_all_reduce_bw_constraint.go; h100-aks-training
69+
# declares nccl-all-reduce-bw with a floor calibrated on ND96isr_H100_v5).
70+
# A100 on Azure is unmeasured and needs its own fabric-aware baseline (#1256)
71+
# plus an aks/a100 support-table entry (#1337), so carrying any floor here
72+
# would only false-fail healthy runs.
7273
validation:
7374
conformance:
7475
checks:

recipes/overlays/h100-aks-training.yaml

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,23 @@ spec:
6666
constraints:
6767
- name: Deployment.gpu-operator.version
6868
value: ">= v24.6.0"
69-
# No performance phase: the nccl-all-reduce-bw check is intentionally not
70-
# declared here. The validator has no AKS NCCL runtime template or
71-
# scheduling path (AKS is not in the support table), so a declared check
72-
# would silently skip and report the phase as passed without measuring
73-
# anything — false assurance. Wire it (testbed-validated AKS runtime
74-
# template + scheduling path + support-table entry + real threshold) once
75-
# an H100 AKS testbed is characterized. See issue #1337.
69+
performance:
70+
checks:
71+
- nccl-all-reduce-bw
72+
# PROVISIONAL floor, set at half the calibrated EKS H100 value (>= 300,
73+
# p5.48xlarge: 8x H100 SXM, 32x EFA). Live baseline: 157.24 GB/s busbw
74+
# measured UNTUNED (16 GiB all-reduce, NCCL built-in IB/verbs with
75+
# NCCL_NET_PLUGIN=none over the network-operator shared HCA pool) on
76+
# aicr-test1, 2x Standard_ND96isr_H100_v5 (8x H100 SXM, 8x 400Gb NDR
77+
# InfiniBand per node), 2026-07-09. There is substantial tuning headroom
78+
# — 8x 400Gb NDR line rate is ~400 GB/s — so recalibrate this floor
79+
# upward once the IB path is tuned rather than treating 157 as the
80+
# fabric's ceiling. See issue #1337; the general fabric-class-aware
81+
# floor work is tracked in
82+
# https://github.com/NVIDIA/aicr/issues/1256.
83+
constraints:
84+
- name: nccl-all-reduce-bw
85+
value: ">= 150"
7686
conformance:
7787
checks:
7888
- platform-health
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"fmt"
19+
"log/slog"
20+
21+
v1 "k8s.io/api/core/v1"
22+
)
23+
24+
// aksRdmaSharedResource is the extended resource published by the network
25+
// operator's rdma-shared-device-plugin on AICR AKS clusters (selector
26+
// vendors=15b3 drivers=mlx5_core). The resource name hca_shared_devices_a is
27+
// pinned by recipes/components/network-operator/manifests/
28+
// nic-cluster-policy-aks.yaml for workload compatibility — keep the two in
29+
// sync. It is a *shared* device pool: a pod that requests one unit is granted
30+
// access to every InfiniBand HCA on the node (/dev/infiniband/*), so NCCL
31+
// workers request exactly 1 regardless of the HCA count.
32+
const aksRdmaSharedResource = "rdma/hca_shared_devices_a"
33+
34+
// discoverAKSRdmaCount reads the allocatable rdma-shared-device-plugin
35+
// resource count from a GPU node. 0 is valid (network-operator RDMA shared
36+
// device plugin not installed) — NCCL falls back to TCP over the pod network,
37+
// mirroring the EKS zero-EFA behavior. On ND-series InfiniBand SKUs
38+
// (e.g. Standard_ND96isr_H100_v5) the plugin advertises a large shared pool
39+
// (allocatable "1k" = 1000).
40+
func discoverAKSRdmaCount(node v1.Node) int {
41+
rdmaQuantity := node.Status.Allocatable[v1.ResourceName(aksRdmaSharedResource)]
42+
return int(rdmaQuantity.Value())
43+
}
44+
45+
// applyAKSTemplateData populates the AKS-specific runtime template variables:
46+
// the ${RDMA_RESOURCE_LIMITS}/${RDMA_RESOURCE_REQUESTS} worker resource lines
47+
// discovered from the first target node, with the same TCP fallback the EKS
48+
// zero-EFA path uses (reduced max message size) when the RDMA shared device
49+
// plugin is absent.
50+
func applyAKSTemplateData(config *gpuConfiguration, templateData map[string]string) {
51+
warnIfHeterogeneousNodes(config.Nodes)
52+
rdmaCount := discoverAKSRdmaCount(config.Nodes[0])
53+
// Indentation matches the resource block position in runtime.yaml.
54+
const rdmaIndent = " "
55+
templateData["RDMA_RESOURCE_LIMITS"] = buildAKSRdmaResourceLine(rdmaCount, rdmaIndent)
56+
templateData["RDMA_RESOURCE_REQUESTS"] = buildAKSRdmaResourceLine(rdmaCount, rdmaIndent)
57+
if rdmaCount == 0 {
58+
templateData["MAX_MESSAGE_SIZE"] = maxMessageSizeTCP
59+
slog.Warn("No shared RDMA devices found — NCCL will use TCP (reduced bandwidth)",
60+
"resource", aksRdmaSharedResource, "maxMessageSize", maxMessageSizeTCP)
61+
} else {
62+
slog.Info("Discovered AKS shared RDMA device pool", "resource", aksRdmaSharedResource, "allocatable", rdmaCount)
63+
}
64+
}
65+
66+
// buildAKSRdmaResourceLine returns the YAML line requesting one unit of the
67+
// shared RDMA resource at the correct indentation, or an empty string when
68+
// the node advertises none (TCP fallback — the placeholder line is dropped
69+
// from the rendered runtime). Requesting "1" (not the pool size) is the
70+
// rdma-shared-device-plugin contract: any positive request mounts every
71+
// shared IB device into the container.
72+
func buildAKSRdmaResourceLine(rdmaCount int, indent string) string {
73+
if rdmaCount == 0 {
74+
return ""
75+
}
76+
return fmt.Sprintf("%s%s: \"1\"", indent, aksRdmaSharedResource)
77+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"testing"
19+
20+
v1 "k8s.io/api/core/v1"
21+
"k8s.io/apimachinery/pkg/api/resource"
22+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
)
24+
25+
func TestDiscoverAKSRdmaCount(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
node v1.Node
29+
want int
30+
}{
31+
{
32+
// Standard_ND96isr_H100_v5 with the network-operator
33+
// rdma-shared-device-plugin: allocatable is advertised as "1k"
34+
// (the shared pool size), which resource.Quantity parses as 1000.
35+
name: "ND96isr_H100_v5 with shared RDMA pool",
36+
node: v1.Node{
37+
ObjectMeta: metav1.ObjectMeta{
38+
Labels: map[string]string{
39+
"node.kubernetes.io/instance-type": "Standard_ND96isr_H100_v5",
40+
},
41+
},
42+
Status: v1.NodeStatus{
43+
Allocatable: v1.ResourceList{
44+
v1.ResourceName("nvidia.com/gpu"): resource.MustParse("8"),
45+
v1.ResourceName(aksRdmaSharedResource): resource.MustParse("1k"),
46+
},
47+
},
48+
},
49+
want: 1000,
50+
},
51+
{
52+
name: "no RDMA shared device plugin (falls back to TCP)",
53+
node: v1.Node{
54+
Status: v1.NodeStatus{
55+
Allocatable: v1.ResourceList{
56+
v1.ResourceName("nvidia.com/gpu"): resource.MustParse("8"),
57+
},
58+
},
59+
},
60+
want: 0,
61+
},
62+
{
63+
name: "no allocatable at all",
64+
node: v1.Node{},
65+
want: 0,
66+
},
67+
}
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
if got := discoverAKSRdmaCount(tt.node); got != tt.want {
71+
t.Errorf("discoverAKSRdmaCount() = %d, want %d", got, tt.want)
72+
}
73+
})
74+
}
75+
}
76+
77+
func TestApplyAKSTemplateData(t *testing.T) {
78+
const rdmaLine = ` rdma/hca_shared_devices_a: "1"`
79+
tests := []struct {
80+
name string
81+
allocatable v1.ResourceList
82+
wantLine string
83+
wantMaxMsgSize string
84+
}{
85+
{
86+
name: "shared RDMA pool present keeps IB message size",
87+
allocatable: v1.ResourceList{
88+
v1.ResourceName("nvidia.com/gpu"): resource.MustParse("8"),
89+
v1.ResourceName(aksRdmaSharedResource): resource.MustParse("1k"),
90+
},
91+
wantLine: rdmaLine,
92+
wantMaxMsgSize: maxMessageSize,
93+
},
94+
{
95+
name: "no RDMA pool falls back to TCP with reduced message size",
96+
allocatable: v1.ResourceList{
97+
v1.ResourceName("nvidia.com/gpu"): resource.MustParse("8"),
98+
},
99+
wantLine: "",
100+
wantMaxMsgSize: maxMessageSizeTCP,
101+
},
102+
}
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
config := &gpuConfiguration{
106+
WorkerCount: 2,
107+
GPUCountPerNode: 8,
108+
TotalGPUCount: 16,
109+
Nodes: []v1.Node{
110+
{Status: v1.NodeStatus{Allocatable: tt.allocatable}},
111+
{Status: v1.NodeStatus{Allocatable: tt.allocatable}},
112+
},
113+
}
114+
templateData := map[string]string{"MAX_MESSAGE_SIZE": maxMessageSize}
115+
applyAKSTemplateData(config, templateData)
116+
if got := templateData["RDMA_RESOURCE_LIMITS"]; got != tt.wantLine {
117+
t.Errorf("RDMA_RESOURCE_LIMITS = %q, want %q", got, tt.wantLine)
118+
}
119+
if got := templateData["RDMA_RESOURCE_REQUESTS"]; got != tt.wantLine {
120+
t.Errorf("RDMA_RESOURCE_REQUESTS = %q, want %q", got, tt.wantLine)
121+
}
122+
if got := templateData["MAX_MESSAGE_SIZE"]; got != tt.wantMaxMsgSize {
123+
t.Errorf("MAX_MESSAGE_SIZE = %q, want %q", got, tt.wantMaxMsgSize)
124+
}
125+
})
126+
}
127+
}
128+
129+
func TestBuildAKSRdmaResourceLine(t *testing.T) {
130+
tests := []struct {
131+
name string
132+
rdmaCount int
133+
indent string
134+
want string
135+
}{
136+
{
137+
// A worker always requests exactly 1 unit, never the pool size:
138+
// the rdma-shared-device-plugin grants access to every shared IB
139+
// device per unit requested.
140+
name: "shared pool of 1000 requests a single unit",
141+
rdmaCount: 1000,
142+
indent: " ",
143+
want: ` rdma/hca_shared_devices_a: "1"`,
144+
},
145+
{
146+
name: "single device still requests one unit",
147+
rdmaCount: 1,
148+
indent: " ",
149+
want: ` rdma/hca_shared_devices_a: "1"`,
150+
},
151+
{
152+
name: "no RDMA — empty string",
153+
rdmaCount: 0,
154+
indent: " ",
155+
want: "",
156+
},
157+
}
158+
for _, tt := range tests {
159+
t.Run(tt.name, func(t *testing.T) {
160+
got := buildAKSRdmaResourceLine(tt.rdmaCount, tt.indent)
161+
if got != tt.want {
162+
t.Errorf("buildAKSRdmaResourceLine(%d) = %q, want %q", tt.rdmaCount, got, tt.want)
163+
}
164+
})
165+
}
166+
}

0 commit comments

Comments
 (0)