-
Notifications
You must be signed in to change notification settings - Fork 793
feat: add scheduler NUMA refit for device allocations #2731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
hami-robot
merged 11 commits into
Project-HAMi:master
from
saiyam1814:feat/numa-refit-endpoint
Aug 25, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
1c81382
feat(util): support strict NUMA alignment
saiyam1814 fa84afe
feat(device): add ReplacePodDevices
saiyam1814 7f8b160
feat(scheduler): add NUMA refit endpoint
saiyam1814 dfc6cea
feat(device-plugin): refit on NUMA allocation mismatch
saiyam1814 ae759f1
feat(helm): add NUMA refit configuration
saiyam1814 e323b5c
fix(scheduler): refuse heterogeneous reservations in refit
saiyam1814 b3662c7
fix(helm): require explicit tlsInsecure for the refit
saiyam1814 f4494ae
chore: address review nitpicks in the refit path
saiyam1814 5447a3a
fix(scheduler): validate refit container index against the pod spec
saiyam1814 515e2c3
fix(device-plugin): fail allocation when a committed refit cannot be …
saiyam1814 7e54be3
feat(helm): mount an optional CA secret for the refit
saiyam1814 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
221 changes: 221 additions & 0 deletions
221
pkg/device-plugin/nvidiadevice/nvinternal/plugin/numa_refit_client.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| /* | ||
| Copyright 2026 The HAMi Authors. | ||
|
|
||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||
| you may not use this file except in compliance with the License. | ||
| You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, software | ||
| distributed under the License is distributed on an "AS IS" BASIS, | ||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| See the License for the specific language governing permissions and | ||
| limitations under the License. | ||
| */ | ||
|
|
||
| package plugin | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "os" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| corev1 "k8s.io/api/core/v1" | ||
| "k8s.io/klog/v2" | ||
| kubeletdevicepluginv1beta1 "k8s.io/kubelet/pkg/apis/deviceplugin/v1beta1" | ||
|
|
||
| "github.com/Project-HAMi/HAMi/pkg/device" | ||
| "github.com/Project-HAMi/HAMi/pkg/device/nvidia" | ||
| "github.com/Project-HAMi/HAMi/pkg/util" | ||
| ) | ||
|
|
||
| const ( | ||
| // SchedulerEndpointEnvName holds the HAMi scheduler base URL used for | ||
| // the NUMA refit, for example https://hami-scheduler.kube-system.svc:443. | ||
| // Empty disables the refit: mismatches are then only logged, exactly as | ||
| // before the refit existed. | ||
| SchedulerEndpointEnvName = "HAMI_SCHEDULER_ENDPOINT" | ||
| // SchedulerCAFileEnvName optionally points at a PEM bundle used to | ||
| // verify the scheduler endpoint's TLS certificate. | ||
| SchedulerCAFileEnvName = "HAMI_SCHEDULER_CA_FILE" | ||
| // SchedulerTLSInsecureEnvName set to true skips TLS verification of the | ||
| // scheduler endpoint. The scheduler serves the admission webhook's | ||
| // self-signed certificate, so the chart enables this by default with the | ||
| // same posture as the extender configmap (tlsConfig.insecure: true). | ||
| SchedulerTLSInsecureEnvName = "HAMI_SCHEDULER_TLS_INSECURE" | ||
|
|
||
| numaRefitPath = "/refit" | ||
|
|
||
| // numaRefitTimeout bounds one refit round trip. Kubelet applies no | ||
| // deadline of its own to GetPreferredAllocation and admits pods on a | ||
| // single serialized loop, so this client timeout is the node's only | ||
| // protection against a slow or unreachable scheduler. | ||
| numaRefitTimeout = 2 * time.Second | ||
| ) | ||
|
|
||
| // numaRefitTLSConfig verifies the scheduler certificate by default, against | ||
| // SchedulerCAFileEnvName when provided; SchedulerTLSInsecureEnvName is an | ||
| // explicit operator opt-out for the self-signed webhook certificate. | ||
| func numaRefitTLSConfig() *tls.Config { | ||
| config := &tls.Config{MinVersion: tls.VersionTLS12} | ||
| if caFile := os.Getenv(SchedulerCAFileEnvName); caFile != "" { | ||
| pem, err := os.ReadFile(caFile) | ||
| if err != nil { | ||
| klog.ErrorS(err, "cannot read scheduler CA bundle", "path", caFile) | ||
| } else if pool := x509.NewCertPool(); pool.AppendCertsFromPEM(pem) { | ||
| config.RootCAs = pool | ||
| } else { | ||
| klog.ErrorS(nil, "scheduler CA bundle contains no usable certificates", "path", caFile) | ||
| } | ||
| } | ||
| if insecure, err := strconv.ParseBool(os.Getenv(SchedulerTLSInsecureEnvName)); err == nil { | ||
| config.InsecureSkipVerify = insecure | ||
| } | ||
| return config | ||
| } | ||
|
|
||
| // numaRefitHTTPClient reaches the scheduler service. | ||
| var numaRefitHTTPClient = &http.Client{ | ||
| Timeout: numaRefitTimeout, | ||
| Transport: &http.Transport{ | ||
| TLSClientConfig: numaRefitTLSConfig(), | ||
| }, | ||
| } | ||
|
|
||
| // tryNumaRefit asks the scheduler to move this container's pending | ||
| // allocation onto kubelet's allowed device set. It returns the preferred | ||
| // replica IDs on success. A nil slice with a nil error means the refit did | ||
| // not apply (disabled, pod not opted in, or best-effort failure); a non-nil | ||
| // error means strict mode failed and the allocation must fail. | ||
| func (plugin *NvidiaDevicePlugin) tryNumaRefit(ctx context.Context, pod *corev1.Pod, containerIndex int, req *kubeletdevicepluginv1beta1.ContainerPreferredAllocationRequest, cause error) ([]string, error) { | ||
| if pod == nil || plugin.operatingMode == nvidia.MigMode || !errors.Is(cause, errAnnotatedDeviceUnavailable) { | ||
| return nil, nil | ||
| } | ||
| mode, parseErr := util.GetNumaAlignmentModeByPod(pod) | ||
| if parseErr != nil || mode == util.NumaAlignmentNone { | ||
| return nil, nil | ||
| } | ||
| if os.Getenv(SchedulerEndpointEnvName) == "" { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // When kubelet pins replicas via MustIncludeDeviceIDs, only their | ||
| // physical devices can satisfy the allocation, so restrict the refit to | ||
| // them; otherwise any available physical device is eligible. | ||
| allowedUUIDs := allowedPhysicalDeviceIDs(req.AvailableDeviceIDs) | ||
| if len(req.MustIncludeDeviceIDs) > 0 { | ||
| allowedUUIDs = allowedPhysicalDeviceIDs(req.MustIncludeDeviceIDs) | ||
| } | ||
| newDevices, err := plugin.requestNumaRefit(ctx, pod, containerIndex, allowedUUIDs) | ||
| if err == nil { | ||
| replicas, selectErr := plugin.selectPreferredDeviceIDsFromAnnotatedDevices(req.AvailableDeviceIDs, req.MustIncludeDeviceIDs, newDevices, int(req.AllocationSize)) | ||
| if selectErr == nil { | ||
| klog.InfoS("NUMA refit succeeded", "pod", klog.KObj(pod), "container", containerIndex, "devices", replicas) | ||
| return replicas, nil | ||
| } | ||
| // The scheduler has already committed the move at this point. | ||
| // Falling back to kubelet's own selection would leave runtime and | ||
| // accounting divergent, so fail the allocation in both modes. | ||
| return nil, fmt.Errorf("numa refit committed but kubelet cannot honor the selection: %w", selectErr) | ||
| } | ||
|
|
||
| if mode == util.NumaAlignmentStrict { | ||
| return nil, fmt.Errorf("numa-alignment strict: %w", err) | ||
| } | ||
| klog.InfoS("NUMA refit failed; best-effort keeps kubelet's own selection", "pod", klog.KObj(pod), "container", containerIndex, "err", err) | ||
| return nil, nil | ||
|
saiyam1814 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // requestNumaRefit performs one refit round trip against the scheduler. | ||
| func (plugin *NvidiaDevicePlugin) requestNumaRefit(ctx context.Context, pod *corev1.Pod, containerIndex int, allowedUUIDs []string) (device.ContainerDevices, error) { | ||
| payload, err := json.Marshal(device.NumaRefitRequest{ | ||
| PodUID: string(pod.UID), | ||
| PodNamespace: pod.Namespace, | ||
| PodName: pod.Name, | ||
| NodeName: os.Getenv(util.NodeNameEnvName), | ||
| ContainerIndex: containerIndex, | ||
| ContainerName: podContainerNameAt(pod, containerIndex), | ||
| DeviceType: nvidia.NvidiaGPUDevice, | ||
| AllowedDeviceUUIDs: allowedUUIDs, | ||
| }) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| ctx, cancel := context.WithTimeout(ctx, numaRefitTimeout) | ||
| defer cancel() | ||
| url := strings.TrimSuffix(os.Getenv(SchedulerEndpointEnvName), "/") + numaRefitPath | ||
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| httpReq.Header.Set("Content-Type", "application/json") | ||
|
|
||
| httpResp, err := numaRefitHTTPClient.Do(httpReq) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer httpResp.Body.Close() | ||
| if httpResp.StatusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("scheduler refit returned status %d", httpResp.StatusCode) | ||
| } | ||
|
|
||
| var response device.NumaRefitResponse | ||
| if err := json.NewDecoder(io.LimitReader(httpResp.Body, 1<<20)).Decode(&response); err != nil { | ||
| return nil, err | ||
| } | ||
| if !response.Succeeded { | ||
| return nil, fmt.Errorf("scheduler refused refit: %s", response.FailureReason) | ||
| } | ||
| devices, err := device.DecodeContainerDevices(response.ContainerDevices) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("cannot decode refit devices: %w", err) | ||
| } | ||
| if len(devices) == 0 { | ||
| return nil, errors.New("scheduler refit returned no devices") | ||
| } | ||
| return devices, nil | ||
| } | ||
|
|
||
| // podContainerNameAt returns the pod's container name at the PodDevices | ||
| // position, counting init containers first, for the scheduler's cross-check. | ||
| func podContainerNameAt(pod *corev1.Pod, index int) string { | ||
| if index < 0 { | ||
| return "" | ||
| } | ||
| if index < len(pod.Spec.InitContainers) { | ||
| return pod.Spec.InitContainers[index].Name | ||
| } | ||
| index -= len(pod.Spec.InitContainers) | ||
| if index >= 0 && index < len(pod.Spec.Containers) { | ||
| return pod.Spec.Containers[index].Name | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| // allowedPhysicalDeviceIDs maps kubelet's replica IDs to their unique | ||
| // physical device UUIDs, preserving first-seen order. | ||
| func allowedPhysicalDeviceIDs(available []string) []string { | ||
| seen := make(map[string]struct{}, len(available)) | ||
| physical := make([]string, 0, len(available)) | ||
| for _, id := range available { | ||
| p := physicalDeviceID(id) | ||
| if _, ok := seen[p]; ok { | ||
| continue | ||
| } | ||
| seen[p] = struct{}{} | ||
| physical = append(physical, p) | ||
| } | ||
| return physical | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.