Skip to content

Commit bf05749

Browse files
authored
feat: add scheduler NUMA refit for device allocations (#2731)
* feat(util): support strict NUMA alignment Adds strict as a valid hami.io/numa-alignment mode. When NUMA refit is enabled, strict mode fails the allocation if the mismatch cannot be fixed. Without refit enabled, it only logs the mismatch as an error. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * feat(device): add ReplacePodDevices Adds ReplacePodDevices for replacing a pod's tracked devices without triggering init-container resource release. This is used by NUMA refit when moving an existing reservation to different devices. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * feat(scheduler): add NUMA refit endpoint Adds the scheduler-side NUMA refit handler and /refit route. The handler re-runs device fitting using the devices allowed by kubelet, updates the affected container's allocation annotations, and rebuilds the pod's reservation from the updated annotations. It also serializes refit with normal scheduling and rejects unsupported or invalid requests without changing the existing allocation. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * feat(device-plugin): refit on NUMA allocation mismatch When the GPU selected by the scheduler is not available to kubelet, ask the scheduler to refit the allocation onto one of kubelet's allowed devices. Best-effort mode keeps the existing fallback behavior if refit fails, while strict mode fails the allocation. The refit client is only enabled when HAMI_SCHEDULER_ENDPOINT is configured. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * feat(helm): add NUMA refit configuration Adds devicePlugin.numaRefit Helm configuration. When enabled, the device plugin is given the scheduler endpoint and TLS settings needed for NUMA refit. The feature remains disabled by default. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * fix(scheduler): refuse heterogeneous reservations in refit A fit request carries one memory/core amount for all devices, so a reservation with differing per-device amounts (possible with percentage requests on mixed GPUs) cannot be re-fit faithfully. Refuse it instead of rewriting the other devices' accounting. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * fix(helm): require explicit tlsInsecure for the refit Only set HAMI_SCHEDULER_TLS_INSECURE when tlsInsecure is explicitly true. A values file that omits the key now gets certificate verification instead of silently skipping it. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * chore: address review nitpicks in the refit path Drops the unused request body copy in the refit route and guards podContainerNameAt against a negative index, matching its scheduler-side twin. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * fix(scheduler): validate refit container index against the pod spec Rejects indexes with no matching init or regular container, and adds a resource-version precondition to the annotation patch so a stale refit cannot overwrite a newer update; conflicts fail instead of retrying. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * fix(device-plugin): fail allocation when a committed refit cannot be honored Once the scheduler has moved the reservation, falling back to kubelet's own selection would leave runtime and accounting divergent, so an unmappable refit response now fails the allocation in both modes. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> * feat(helm): mount an optional CA secret for the refit devicePlugin.numaRefit.caSecret mounts a Secret (key ca.crt) read-only and points HAMI_SCHEDULER_CA_FILE at it, so verified TLS needs no manual file placement. caFile keeps working for pre-provisioned paths. Signed-off-by: Saiyam Pathak <saiyam911@gmail.com> --------- Signed-off-by: Saiyam Pathak <saiyam911@gmail.com>
1 parent ea9915e commit bf05749

17 files changed

Lines changed: 1585 additions & 21 deletions

File tree

charts/hami/templates/device-plugin/daemonsetnvidia.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ spec:
122122
- name: ENABLE_TOPOLOGY_SCORE
123123
value: "true"
124124
{{- end }}
125+
{{- with .Values.devicePlugin.numaRefit }}
126+
{{- if .enabled }}
127+
- name: HAMI_SCHEDULER_ENDPOINT
128+
value: {{ .schedulerEndpoint | default (printf "https://%s.%s.svc:%v" (include "hami-vgpu.scheduler" $) (include "hami-vgpu.namespace" $) ($.Values.scheduler.service.httpPort | default 443)) | quote }}
129+
{{- if .tlsInsecure }}
130+
- name: HAMI_SCHEDULER_TLS_INSECURE
131+
value: "true"
132+
{{- end }}
133+
{{- if .caFile }}
134+
- name: HAMI_SCHEDULER_CA_FILE
135+
value: {{ .caFile | quote }}
136+
{{- else if .caSecret }}
137+
- name: HAMI_SCHEDULER_CA_FILE
138+
value: "/etc/hami/numa-refit-ca/ca.crt"
139+
{{- end }}
140+
{{- end }}
141+
{{- end }}
125142
{{- with .Values.devicePlugin.extraEnvs }}
126143
{{- . | toYaml | nindent 12 }}
127144
{{- end }}
@@ -147,6 +164,11 @@ spec:
147164
subPath: device-config.yaml
148165
- name: cdi-root
149166
mountPath: /var/run/cdi
167+
{{- if and .Values.devicePlugin.numaRefit .Values.devicePlugin.numaRefit.enabled .Values.devicePlugin.numaRefit.caSecret }}
168+
- name: numa-refit-ca
169+
mountPath: /etc/hami/numa-refit-ca
170+
readOnly: true
171+
{{- end }}
150172
{{- if typeIs "string" .Values.devicePlugin.nvidiaDriverRoot }}
151173
# We always mount the driver root at /driver-root in the container.
152174
# This is required for CDI detection to work correctly.
@@ -205,6 +227,11 @@ spec:
205227
- name: hosttmp
206228
mountPath: /tmp
207229
volumes:
230+
{{- if and .Values.devicePlugin.numaRefit .Values.devicePlugin.numaRefit.enabled .Values.devicePlugin.numaRefit.caSecret }}
231+
- name: numa-refit-ca
232+
secret:
233+
secretName: {{ .Values.devicePlugin.numaRefit.caSecret }}
234+
{{- end }}
208235
- name: ctrs
209236
hostPath:
210237
path: {{ .Values.devicePlugin.monitor.ctrPath }}

charts/hami/values.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,23 @@ devicePlugin:
351351
# TopologyManager can align CPU and GPU NUMA nodes. Opt-in because it changes
352352
# admission behavior when topologyManagerPolicy is single-numa-node.
353353
enableNumaTopology: false
354+
# NUMA alignment refit (#2080): when enabled, the device plugin asks the
355+
# scheduler to re-run its fit over the NUMA-restricted device set kubelet
356+
# allows, keeping scheduler accounting authoritative. Requires
357+
# enableNumaTopology plus the per-node enablegetpreferredallocation node
358+
# configuration; pods opt in with the hami.io/numa-alignment annotation
359+
# (best-effort or strict). Disabled by default: mismatches are only logged.
360+
numaRefit:
361+
enabled: false
362+
# Scheduler base URL override. Defaults to the in-cluster scheduler
363+
# service, https://<release>-scheduler.<namespace>.svc:<httpPort>.
364+
schedulerEndpoint: ""
365+
# The scheduler serves the admission webhook's self-signed certificate,
366+
# so verification is skipped by default with the same posture as the
367+
# extender configmap (tlsConfig.insecure). Set false to verify, and use
368+
# caFile to point at a CA bundle mounted into the device plugin.
369+
tlsInsecure: true
370+
caFile: ""
354371
# Pre-configured device memory in MB for GPUs that don't support memory query (e.g., unified memory architecture GPUs like NVIDIA GB10/DGX Spark).
355372
# Set to 0 to use auto-detection (default). For unified memory GPUs, set to the total GPU memory (e.g., 131072 for 128GB).
356373
# Can be overridden per-node via nodeConfiguration.config.

cmd/scheduler/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ func start() error {
145145
router := httprouter.New()
146146
router.POST("/filter", routes.PredicateRoute(sher))
147147
router.POST("/bind", routes.Bind(sher))
148+
router.POST("/refit", routes.NumaRefit(sher))
148149
router.POST("/webhook", routes.WebHookRoute())
149150
router.GET("/healthz", routes.HealthzRoute())
150151
router.GET("/readyz", routes.ReadyzRoute(sher))
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
Copyright 2026 The HAMi Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package plugin
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"crypto/tls"
23+
"crypto/x509"
24+
"encoding/json"
25+
"errors"
26+
"fmt"
27+
"io"
28+
"net/http"
29+
"os"
30+
"strconv"
31+
"strings"
32+
"time"
33+
34+
corev1 "k8s.io/api/core/v1"
35+
"k8s.io/klog/v2"
36+
kubeletdevicepluginv1beta1 "k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1"
37+
38+
"github.com/Project-HAMi/HAMi/pkg/device"
39+
"github.com/Project-HAMi/HAMi/pkg/device/nvidia"
40+
"github.com/Project-HAMi/HAMi/pkg/util"
41+
)
42+
43+
const (
44+
// SchedulerEndpointEnvName holds the HAMi scheduler base URL used for
45+
// the NUMA refit, for example https://hami-scheduler.kube-system.svc:443.
46+
// Empty disables the refit: mismatches are then only logged, exactly as
47+
// before the refit existed.
48+
SchedulerEndpointEnvName = "HAMI_SCHEDULER_ENDPOINT"
49+
// SchedulerCAFileEnvName optionally points at a PEM bundle used to
50+
// verify the scheduler endpoint's TLS certificate.
51+
SchedulerCAFileEnvName = "HAMI_SCHEDULER_CA_FILE"
52+
// SchedulerTLSInsecureEnvName set to true skips TLS verification of the
53+
// scheduler endpoint. The scheduler serves the admission webhook's
54+
// self-signed certificate, so the chart enables this by default with the
55+
// same posture as the extender configmap (tlsConfig.insecure: true).
56+
SchedulerTLSInsecureEnvName = "HAMI_SCHEDULER_TLS_INSECURE"
57+
58+
numaRefitPath = "/refit"
59+
60+
// numaRefitTimeout bounds one refit round trip. Kubelet applies no
61+
// deadline of its own to GetPreferredAllocation and admits pods on a
62+
// single serialized loop, so this client timeout is the node's only
63+
// protection against a slow or unreachable scheduler.
64+
numaRefitTimeout = 2 * time.Second
65+
)
66+
67+
// numaRefitTLSConfig verifies the scheduler certificate by default, against
68+
// SchedulerCAFileEnvName when provided; SchedulerTLSInsecureEnvName is an
69+
// explicit operator opt-out for the self-signed webhook certificate.
70+
func numaRefitTLSConfig() *tls.Config {
71+
config := &tls.Config{MinVersion: tls.VersionTLS12}
72+
if caFile := os.Getenv(SchedulerCAFileEnvName); caFile != "" {
73+
pem, err := os.ReadFile(caFile)
74+
if err != nil {
75+
klog.ErrorS(err, "cannot read scheduler CA bundle", "path", caFile)
76+
} else if pool := x509.NewCertPool(); pool.AppendCertsFromPEM(pem) {
77+
config.RootCAs = pool
78+
} else {
79+
klog.ErrorS(nil, "scheduler CA bundle contains no usable certificates", "path", caFile)
80+
}
81+
}
82+
if insecure, err := strconv.ParseBool(os.Getenv(SchedulerTLSInsecureEnvName)); err == nil {
83+
config.InsecureSkipVerify = insecure
84+
}
85+
return config
86+
}
87+
88+
// numaRefitHTTPClient reaches the scheduler service.
89+
var numaRefitHTTPClient = &http.Client{
90+
Timeout: numaRefitTimeout,
91+
Transport: &http.Transport{
92+
TLSClientConfig: numaRefitTLSConfig(),
93+
},
94+
}
95+
96+
// tryNumaRefit asks the scheduler to move this container's pending
97+
// allocation onto kubelet's allowed device set. It returns the preferred
98+
// replica IDs on success. A nil slice with a nil error means the refit did
99+
// not apply (disabled, pod not opted in, or best-effort failure); a non-nil
100+
// error means strict mode failed and the allocation must fail.
101+
func (plugin *NvidiaDevicePlugin) tryNumaRefit(ctx context.Context, pod *corev1.Pod, containerIndex int, req *kubeletdevicepluginv1beta1.ContainerPreferredAllocationRequest, cause error) ([]string, error) {
102+
if pod == nil || plugin.operatingMode == nvidia.MigMode || !errors.Is(cause, errAnnotatedDeviceUnavailable) {
103+
return nil, nil
104+
}
105+
mode, parseErr := util.GetNumaAlignmentModeByPod(pod)
106+
if parseErr != nil || mode == util.NumaAlignmentNone {
107+
return nil, nil
108+
}
109+
if os.Getenv(SchedulerEndpointEnvName) == "" {
110+
return nil, nil
111+
}
112+
113+
// When kubelet pins replicas via MustIncludeDeviceIDs, only their
114+
// physical devices can satisfy the allocation, so restrict the refit to
115+
// them; otherwise any available physical device is eligible.
116+
allowedUUIDs := allowedPhysicalDeviceIDs(req.AvailableDeviceIDs)
117+
if len(req.MustIncludeDeviceIDs) > 0 {
118+
allowedUUIDs = allowedPhysicalDeviceIDs(req.MustIncludeDeviceIDs)
119+
}
120+
newDevices, err := plugin.requestNumaRefit(ctx, pod, containerIndex, allowedUUIDs)
121+
if err == nil {
122+
replicas, selectErr := plugin.selectPreferredDeviceIDsFromAnnotatedDevices(req.AvailableDeviceIDs, req.MustIncludeDeviceIDs, newDevices, int(req.AllocationSize))
123+
if selectErr == nil {
124+
klog.InfoS("NUMA refit succeeded", "pod", klog.KObj(pod), "container", containerIndex, "devices", replicas)
125+
return replicas, nil
126+
}
127+
// The scheduler has already committed the move at this point.
128+
// Falling back to kubelet's own selection would leave runtime and
129+
// accounting divergent, so fail the allocation in both modes.
130+
return nil, fmt.Errorf("numa refit committed but kubelet cannot honor the selection: %w", selectErr)
131+
}
132+
133+
if mode == util.NumaAlignmentStrict {
134+
return nil, fmt.Errorf("numa-alignment strict: %w", err)
135+
}
136+
klog.InfoS("NUMA refit failed; best-effort keeps kubelet's own selection", "pod", klog.KObj(pod), "container", containerIndex, "err", err)
137+
return nil, nil
138+
}
139+
140+
// requestNumaRefit performs one refit round trip against the scheduler.
141+
func (plugin *NvidiaDevicePlugin) requestNumaRefit(ctx context.Context, pod *corev1.Pod, containerIndex int, allowedUUIDs []string) (device.ContainerDevices, error) {
142+
payload, err := json.Marshal(device.NumaRefitRequest{
143+
PodUID: string(pod.UID),
144+
PodNamespace: pod.Namespace,
145+
PodName: pod.Name,
146+
NodeName: os.Getenv(util.NodeNameEnvName),
147+
ContainerIndex: containerIndex,
148+
ContainerName: podContainerNameAt(pod, containerIndex),
149+
DeviceType: nvidia.NvidiaGPUDevice,
150+
AllowedDeviceUUIDs: allowedUUIDs,
151+
})
152+
if err != nil {
153+
return nil, err
154+
}
155+
156+
ctx, cancel := context.WithTimeout(ctx, numaRefitTimeout)
157+
defer cancel()
158+
url := strings.TrimSuffix(os.Getenv(SchedulerEndpointEnvName), "/") + numaRefitPath
159+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
160+
if err != nil {
161+
return nil, err
162+
}
163+
httpReq.Header.Set("Content-Type", "application/json")
164+
165+
httpResp, err := numaRefitHTTPClient.Do(httpReq)
166+
if err != nil {
167+
return nil, err
168+
}
169+
defer httpResp.Body.Close()
170+
if httpResp.StatusCode != http.StatusOK {
171+
return nil, fmt.Errorf("scheduler refit returned status %d", httpResp.StatusCode)
172+
}
173+
174+
var response device.NumaRefitResponse
175+
if err := json.NewDecoder(io.LimitReader(httpResp.Body, 1<<20)).Decode(&response); err != nil {
176+
return nil, err
177+
}
178+
if !response.Succeeded {
179+
return nil, fmt.Errorf("scheduler refused refit: %s", response.FailureReason)
180+
}
181+
devices, err := device.DecodeContainerDevices(response.ContainerDevices)
182+
if err != nil {
183+
return nil, fmt.Errorf("cannot decode refit devices: %w", err)
184+
}
185+
if len(devices) == 0 {
186+
return nil, errors.New("scheduler refit returned no devices")
187+
}
188+
return devices, nil
189+
}
190+
191+
// podContainerNameAt returns the pod's container name at the PodDevices
192+
// position, counting init containers first, for the scheduler's cross-check.
193+
func podContainerNameAt(pod *corev1.Pod, index int) string {
194+
if index < 0 {
195+
return ""
196+
}
197+
if index < len(pod.Spec.InitContainers) {
198+
return pod.Spec.InitContainers[index].Name
199+
}
200+
index -= len(pod.Spec.InitContainers)
201+
if index >= 0 && index < len(pod.Spec.Containers) {
202+
return pod.Spec.Containers[index].Name
203+
}
204+
return ""
205+
}
206+
207+
// allowedPhysicalDeviceIDs maps kubelet's replica IDs to their unique
208+
// physical device UUIDs, preserving first-seen order.
209+
func allowedPhysicalDeviceIDs(available []string) []string {
210+
seen := make(map[string]struct{}, len(available))
211+
physical := make([]string, 0, len(available))
212+
for _, id := range available {
213+
p := physicalDeviceID(id)
214+
if _, ok := seen[p]; ok {
215+
continue
216+
}
217+
seen[p] = struct{}{}
218+
physical = append(physical, p)
219+
}
220+
return physical
221+
}

0 commit comments

Comments
 (0)