Skip to content

Commit c79f723

Browse files
Raya SolanoTensorRaya
authored andcommitted
fix(scheduler): count shared DRA GPU device once per node
A single physical GPU shared by several pods through one ResourceClaim (status.reservedFor with more than one entry) was counted once per consuming pod in the node used vector. On a node whose shared device pushed the used GPU count above physical capacity, IdleVector went negative and the node became unschedulable for every task, including GPU-less ones, surfacing as a misleading "didn't have enough resources: GPUs" message. Track a per-device reference count on the node (keyed by driver/pool/device) so a shared physical device contributes to the used GPU count exactly once, with symmetric add/remove accounting. Signed-off-by: Raya Solano <raya@mbinf.de>
1 parent f23d1e4 commit c79f723

6 files changed

Lines changed: 329 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
- Count a physical GPU shared by several pods through one DRA ResourceClaim (`status.reservedFor` with more than one entry) once per node instead of once per consuming pod. Per-task accounting added the device for every consumer, so on a node whose shared device pushed the used GPU count above physical capacity, `IdleVector` went negative and the node became unschedulable for every task — including GPU-less ones — surfacing as a misleading "didn't have enough resources: GPUs" message. [#1930](https://github.com/kai-scheduler/KAI-Scheduler/issues/1930)
11+
12+
## [v0.16.4] - 2026-07-12
13+
14+
### Added
15+
- Publish FIPS-enabled image variants (`<version>-fips`) for every release, built with the Go toolchain's native FIPS 140-3 mode (`GOFIPS140`), and added a `global.fips` Helm value (default `false`) that appends `-fips` to every resolved image tag ([guide](docs/fips/README.md)). [#1867](https://github.com/kai-scheduler/KAI-Scheduler/issues/1867)
16+
- Added `global.nodePoolLabelKey` Helm value to configure `spec.global.nodePoolLabelKey` in the Config CR for KAI sharding [#1774](https://github.com/kai-scheduler/KAI-Scheduler/issues/1774).
17+
18+
### Fixed
19+
- Scoped the operator's informer cache for Pods, Leases and EndpointSlices to the KAI namespace and stripped managed fields from cached objects. Since v0.15.0 the operator cached every such object in the cluster, so its memory grew with cluster size and exceeded the default 256Mi limit on large clusters. [#1780](https://github.com/kai-scheduler/KAI-Scheduler/issues/1780)
20+
- Block NaN value for fraction in the pod admission [#1798](https://github.com/kai-scheduler/KAI-Scheduler/issues/1798) [davidLif](https://github.com/davidLif)
21+
- In the fractional admission checks, check that the fractional value can be parsed as a quantity. [#1798](https://github.com/kai-scheduler/KAI-Scheduler/issues/1798) [davidLif](https://github.com/davidLif)
22+
23+
## [v0.16.2] - 2026-06-30
24+
25+
### Fixed
26+
- Fixed scheduler pod cache memory growth by transforming cached Pods to retain only scheduler-relevant container fields while stripping large literal env values and managed fields [#1646](https://github.com/kai-scheduler/KAI-Scheduler/issues/1646)
27+
- Fix the MinNodeGPUMemoryMiB calculation in the scheduler. This affected allocations for fractional pod requesting gpu "gpu-memory". [#1795](https://github.com/kai-scheduler/KAI-Scheduler/issues/1795) [davidLif](https://github.com/davidLif)
28+
- Use the maximum gpu size ine the cluster rather then the minimum when checking a potential overLimit or isNonPreemptebleOverquota for a pod. [#1792](https://github.com/kai-scheduler/KAI-Scheduler/issues/1795) [davidLif](https://github.com/davidLif)
29+
30+
## [v0.16.1] - 2026-06-28
31+
32+
### Added
33+
- Added `global.resourceReservation.createNamespace` Helm value (default `true`) to allow disabling creation of the resource-reservation namespace, for embedding KAI in a parent chart that creates the namespace itself.
34+
- Added `global.resourceReservation.createServiceAccount` Helm value (default `true`) to allow disabling creation of the resource-reservation ServiceAccount, for embedding KAI in a parent chart that creates the ServiceAccount itself.
35+
- Added `defaultPriorityClasses.enabled` Helm value (default `true`) for installations that manage KAI PriorityClasses externally.
36+
37+
### Changed
38+
- Podgrouper now preserves an existing PodGroup's topology constraint when the workload does not specify one, so an externally-assigned topology is not overwritten. Workload topology annotations still take precedence when present.
39+
40+
### Fixed
41+
- Restricted Helm post-delete cleanup to KAI operator-managed Deployments and preserved externally managed `kai-config` resources when `kaiConfigDeployer.enabled=false`.
42+
- Scheduler cache now filters terminal Pods at watch time to reduce memory use, while still watching Pods bound by other schedulers so their resource usage is counted in allocatable calculations. [#1645](https://github.com/kai-scheduler/KAI-Scheduler/issues/1645) [enoodle](https://github.com/enoodle)
43+
- Fixed reclaim abandoning valid over-quota victims when an unrelated under-deserved queue appeared earlier in victim ordering. [#1750](https://github.com/kai-scheduler/KAI-Scheduler/issues/1750)
44+
745
## [v0.16.0] - 2026-06-24
846

947
### Added

pkg/common/constants/constants.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const (
99
AppLabelName = "app"
1010
NvidiaGpuResource = "nvidia.com/gpu"
1111
NvidiaGpuMemory = "nvidia.com/gpu.memory"
12+
NvidiaGpuDraDriver = "gpu.nvidia.com"
1213
NvidiaMigResourcePrefix = "nvidia.com/mig-"
1314
GpuResource = "gpu"
1415
UnlimitedResourceQuantity = float64(-1)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2025 NVIDIA CORPORATION
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package node_info
5+
6+
import (
7+
resourceapi "k8s.io/api/resource/v1"
8+
9+
commonconstants "github.com/kai-scheduler/KAI-scheduler/pkg/common/constants"
10+
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/pod_info"
11+
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/resource_info"
12+
)
13+
14+
// A single physical DRA device may be shared by several pods through one
15+
// ResourceClaim with more than one entry in status.reservedFor. Each such pod
16+
// carries the same allocated device in its ResourceClaimInfo, so counting the
17+
// device once per pod (the naive per-task accounting) inflates the node's used
18+
// GPU count above physical capacity and drives IdleVector negative, making the
19+
// whole node unschedulable. draSharedDeviceRefCount tracks how many pods on the
20+
// node currently reference each allocated device (keyed by driver/pool/device)
21+
// so a shared device contributes to UsedVector exactly once.
22+
23+
// draDeviceKey uniquely identifies a physical DRA device on the node.
24+
func draDeviceKey(result resourceapi.DeviceRequestAllocationResult) string {
25+
return result.Driver + "/" + result.Pool + "/" + result.Device
26+
}
27+
28+
// allocatedGPUDeviceKeys returns the keys of all GPU devices allocated to the
29+
// task via DRA ResourceClaims. Non-GPU devices are ignored: they are not part
30+
// of the GPU accounting that this dedup protects.
31+
func (ni *NodeInfo) allocatedGPUDeviceKeys(task *pod_info.PodInfo) []string {
32+
var keys []string
33+
for _, claimAllocation := range task.ResourceClaimInfo {
34+
if claimAllocation == nil || claimAllocation.Allocation == nil {
35+
continue
36+
}
37+
for _, result := range claimAllocation.Allocation.Devices.Results {
38+
if !isGPUDRADriver(result.Driver) {
39+
continue
40+
}
41+
keys = append(keys, draDeviceKey(result))
42+
}
43+
}
44+
return keys
45+
}
46+
47+
// isGPUDRADriver reports whether the DRA driver name belongs to an NVIDIA GPU
48+
// device. The device-class name is not present on the allocation result, so the
49+
// driver name is used instead.
50+
func isGPUDRADriver(driver string) bool {
51+
return driver == commonconstants.NvidiaGpuDraDriver
52+
}
53+
54+
// dedupSharedDRAGpus removes from resourcesToTrack the GPU count that would
55+
// double-count physical DRA devices already referenced by other pods on the
56+
// node. It also updates the node's per-device reference count. It must be
57+
// called once per addTaskResources, before the vector is added to UsedVector.
58+
func (ni *NodeInfo) dedupSharedDRAGpus(task *pod_info.PodInfo, resourcesToTrack resource_info.ResourceVector) {
59+
alreadyCounted := 0.0
60+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
61+
if ni.DRASharedDeviceRefCount[key] > 0 {
62+
// Another pod on this node already contributed this physical
63+
// device to the used vector: do not count it again.
64+
alreadyCounted++
65+
}
66+
ni.DRASharedDeviceRefCount[key]++
67+
}
68+
69+
if alreadyCounted > 0 {
70+
current := resourcesToTrack.Get(resource_info.GPUIndex)
71+
resourcesToTrack.Set(resource_info.GPUIndex, current-alreadyCounted)
72+
}
73+
}
74+
75+
// releaseSharedDRAGpus is the inverse of dedupSharedDRAGpus: it decrements the
76+
// per-device reference count and adds back the GPU count for devices that
77+
// remain referenced by other pods (and were therefore never subtracted on this
78+
// task's removal path). It must be called once per removeTaskResources.
79+
func (ni *NodeInfo) releaseSharedDRAGpus(task *pod_info.PodInfo, resourcesToTrack resource_info.ResourceVector) {
80+
stillShared := 0.0
81+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
82+
if ni.DRASharedDeviceRefCount[key] > 1 {
83+
// The device stays referenced by another pod after this removal:
84+
// it must remain in the used vector, so this task's removal must
85+
// not subtract it.
86+
stillShared++
87+
}
88+
if ni.DRASharedDeviceRefCount[key] > 0 {
89+
ni.DRASharedDeviceRefCount[key]--
90+
}
91+
if ni.DRASharedDeviceRefCount[key] == 0 {
92+
delete(ni.DRASharedDeviceRefCount, key)
93+
}
94+
}
95+
96+
if stillShared > 0 {
97+
current := resourcesToTrack.Get(resource_info.GPUIndex)
98+
resourcesToTrack.Set(resource_info.GPUIndex, current-stillShared)
99+
}
100+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright 2025 NVIDIA CORPORATION
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package node_info
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"go.uber.org/mock/gomock"
11+
v1 "k8s.io/api/core/v1"
12+
resourceapi "k8s.io/api/resource/v1"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/utils/ptr"
15+
16+
commonconstants "github.com/kai-scheduler/KAI-scheduler/pkg/common/constants"
17+
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/common_info"
18+
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/pod_affinity"
19+
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/pod_info"
20+
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/resource_info"
21+
)
22+
23+
const gpuDeviceClass = "gpu.nvidia.com"
24+
25+
// sharedGPUClaim builds a ResourceClaim that requests one GPU device and is
26+
// allocated to the given physical device. The same claim object is referenced
27+
// by every pod that shares the device (status.reservedFor with multiple
28+
// entries), matching a DRA time-slicing / MPS setup.
29+
func sharedGPUClaim(name, namespace, driver, pool, device string) *resourceapi.ResourceClaim {
30+
return &resourceapi.ResourceClaim{
31+
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
32+
Spec: resourceapi.ResourceClaimSpec{
33+
Devices: resourceapi.DeviceClaim{
34+
Requests: []resourceapi.DeviceRequest{
35+
{
36+
Name: "gpu",
37+
Exactly: &resourceapi.ExactDeviceRequest{
38+
DeviceClassName: gpuDeviceClass,
39+
AllocationMode: resourceapi.DeviceAllocationModeExactCount,
40+
Count: 1,
41+
},
42+
},
43+
},
44+
},
45+
},
46+
Status: resourceapi.ResourceClaimStatus{
47+
Allocation: &resourceapi.AllocationResult{
48+
Devices: resourceapi.DeviceAllocationResult{
49+
Results: []resourceapi.DeviceRequestAllocationResult{
50+
{Request: "gpu", Driver: driver, Pool: pool, Device: device},
51+
},
52+
},
53+
},
54+
},
55+
}
56+
}
57+
58+
// draConsumerPod builds a running pod that consumes the given claim by name.
59+
func draConsumerPod(name, namespace, nodeName, claimName string) *v1.Pod {
60+
pod := common_info.BuildPod(namespace, name, nodeName, v1.PodRunning,
61+
common_info.BuildResourceList("1000m", "1G"), []metav1.OwnerReference{},
62+
map[string]string{}, map[string]string{
63+
pod_info.ReceivedResourceTypeAnnotationName: string(pod_info.ReceivedTypeRegular),
64+
commonconstants.PodGroupAnnotationForPod: common_info.FakePogGroupId,
65+
})
66+
pod.Spec.ResourceClaims = []v1.PodResourceClaim{
67+
{Name: claimName, ResourceClaimName: ptr.To(claimName)},
68+
}
69+
return pod
70+
}
71+
72+
// newGPUNodeInfo builds a NodeInfo for a node with the given whole-GPU count,
73+
// wired with a mock pod-affinity that expects addPods AddPod and rmPods
74+
// RemovePod calls, and a vectorMap that knows the DRA GPU device class.
75+
func newGPUNodeInfo(t *testing.T, name, gpuCount string, addPods, rmPods int) (*NodeInfo, *resource_info.ResourceVectorMap) {
76+
node := common_info.BuildNode(name, common_info.BuildResourceListWithGPU("8000m", "16G", gpuCount))
77+
78+
ctrl := gomock.NewController(t)
79+
affinity := pod_affinity.NewMockNodePodAffinityInfo(ctrl)
80+
affinity.EXPECT().AddPod(gomock.Any()).Times(addPods)
81+
affinity.EXPECT().RemovePod(gomock.Any()).Times(rmPods)
82+
83+
vectorMap := resource_info.NewResourceVectorMap()
84+
for resourceName := range node.Status.Allocatable {
85+
vectorMap.AddResource(resourceName)
86+
}
87+
// DRA GPU counts are tracked under the device-class resource name.
88+
vectorMap.AddResource(v1.ResourceName(gpuDeviceClass))
89+
90+
return NewNodeInfo(node, affinity, vectorMap), vectorMap
91+
}
92+
93+
// TestAddTask_SharedDRAClaimCountedOnce is the regression test for the
94+
// shared-ResourceClaim double count. Two pods share one physical GPU through a
95+
// single claim (reservedFor has both). Naive per-task accounting adds the GPU
96+
// once per pod, driving the node's used GPU count to 2 on a 1-GPU node and
97+
// IdleVector negative, which makes the node unschedulable for every task. The
98+
// fix must keep the used count at exactly 1.
99+
//
100+
// This exercises the real accounting path (AddTask -> addTaskResources ->
101+
// UsedVector), not the dedup helper in isolation, so it fails if the dedup is
102+
// not wired into AddTask.
103+
func TestAddTask_SharedDRAClaimCountedOnce(t *testing.T) {
104+
ni, vectorMap := newGPUNodeInfo(t, "atlas", "1", 2, 0)
105+
106+
claim := sharedGPUClaim("voice-atlas-shared", "voice-pipeline", gpuDeviceClass, "atlas", "gpu-0")
107+
pod1 := draConsumerPod("voice-tts", "voice-pipeline", "atlas", "voice-atlas-shared")
108+
pod2 := draConsumerPod("voice-worker", "voice-pipeline", "atlas", "voice-atlas-shared")
109+
110+
task1 := pod_info.NewTaskInfo(pod1, vectorMap, pod_info.TaskInfoOptions{
111+
DraPodClaims: []*resourceapi.ResourceClaim{claim},
112+
})
113+
task2 := pod_info.NewTaskInfo(pod2, vectorMap, pod_info.TaskInfoOptions{
114+
DraPodClaims: []*resourceapi.ResourceClaim{claim},
115+
})
116+
117+
assert.NoError(t, ni.AddTask(task1))
118+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex),
119+
"first consumer of the shared device must be counted")
120+
121+
assert.NoError(t, ni.AddTask(task2))
122+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex),
123+
"second consumer of the same shared device must not be double-counted")
124+
125+
idleGPUs, _ := ni.GetSumOfIdleGPUs()
126+
assert.Equal(t, 0.0, idleGPUs, "idle GPUs must be 0, never negative, on a fully-shared 1-GPU node")
127+
}
128+
129+
// TestAddRemoveTask_SharedDRAClaimSymmetry verifies the used count follows the
130+
// number of distinct physical devices as consumers are added and removed: it
131+
// stays at 1 while any consumer of the shared device remains, and returns to 0
132+
// only when the last one leaves.
133+
func TestAddRemoveTask_SharedDRAClaimSymmetry(t *testing.T) {
134+
ni, vectorMap := newGPUNodeInfo(t, "atlas", "1", 2, 2)
135+
136+
claim := sharedGPUClaim("voice-atlas-shared", "voice-pipeline", gpuDeviceClass, "atlas", "gpu-0")
137+
pod1 := draConsumerPod("voice-tts", "voice-pipeline", "atlas", "voice-atlas-shared")
138+
pod2 := draConsumerPod("voice-worker", "voice-pipeline", "atlas", "voice-atlas-shared")
139+
task1 := pod_info.NewTaskInfo(pod1, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claim}})
140+
task2 := pod_info.NewTaskInfo(pod2, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claim}})
141+
142+
assert.NoError(t, ni.AddTask(task1))
143+
assert.NoError(t, ni.AddTask(task2))
144+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex))
145+
146+
assert.NoError(t, ni.RemoveTask(task2))
147+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex),
148+
"removing one of two consumers must keep the shared device counted")
149+
150+
assert.NoError(t, ni.RemoveTask(task1))
151+
assert.Equal(t, 0.0, ni.UsedVector.Get(resource_info.GPUIndex),
152+
"removing the last consumer must release the shared device")
153+
}
154+
155+
// TestAddTask_DistinctDRADevicesEachCounted guards against over-dedup: two pods
156+
// on two different physical GPUs (own claim each) must both be counted.
157+
func TestAddTask_DistinctDRADevicesEachCounted(t *testing.T) {
158+
ni, vectorMap := newGPUNodeInfo(t, "nyx", "2", 2, 0)
159+
160+
claimA := sharedGPUClaim("coder-claim", "vllm", gpuDeviceClass, "nyx", "gpu-4")
161+
claimB := sharedGPUClaim("gemma-claim", "vllm", gpuDeviceClass, "nyx", "gpu-6")
162+
podA := draConsumerPod("coder", "vllm", "nyx", "coder-claim")
163+
podB := draConsumerPod("gemma", "vllm", "nyx", "gemma-claim")
164+
taskA := pod_info.NewTaskInfo(podA, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claimA}})
165+
taskB := pod_info.NewTaskInfo(podB, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claimB}})
166+
167+
assert.NoError(t, ni.AddTask(taskA))
168+
assert.NoError(t, ni.AddTask(taskB))
169+
assert.Equal(t, 2.0, ni.UsedVector.Get(resource_info.GPUIndex),
170+
"two distinct physical devices must both be counted")
171+
}

pkg/scheduler/api/node_info/gpu_sharing_node_info.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ type GpuSharingNodeInfo struct {
2121
UsedSharedGPUsMemory map[string]int64
2222
ReleasingSharedGPUsMemory map[string]int64
2323
AllocatedSharedGPUsMemory map[string]int64
24+
25+
// DRASharedDeviceRefCount counts how many pods on the node reference each
26+
// physical DRA GPU device (keyed by driver/pool/device). A device shared
27+
// by several pods through one ResourceClaim (status.reservedFor with more
28+
// than one entry) must contribute to the node's used GPU count only once.
29+
DRASharedDeviceRefCount map[string]int
2430
}
2531

2632
func newGpuSharingNodeInfo() *GpuSharingNodeInfo {
@@ -30,6 +36,8 @@ func newGpuSharingNodeInfo() *GpuSharingNodeInfo {
3036
UsedSharedGPUsMemory: make(map[string]int64),
3137
ReleasingSharedGPUsMemory: make(map[string]int64),
3238
AllocatedSharedGPUsMemory: make(map[string]int64),
39+
40+
DRASharedDeviceRefCount: make(map[string]int),
3341
}
3442
}
3543

@@ -48,6 +56,9 @@ func (g *GpuSharingNodeInfo) Clone() *GpuSharingNodeInfo {
4856
for k, v := range g.AllocatedSharedGPUsMemory {
4957
gpuSharingNodeInfo.AllocatedSharedGPUsMemory[k] = v
5058
}
59+
for k, v := range g.DRASharedDeviceRefCount {
60+
gpuSharingNodeInfo.DRASharedDeviceRefCount[k] = v
61+
}
5162

5263
return gpuSharingNodeInfo
5364
}

pkg/scheduler/api/node_info/node_info.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,10 @@ func (ni *NodeInfo) addTaskResources(task *pod_info.PodInfo) {
466466
resourcesToTrackVector.Set(resource_info.GPUIndex, 0)
467467
}
468468

469+
// A physical DRA device shared by several pods (one ResourceClaim with
470+
// multiple reservedFor entries) must be counted once, not once per pod.
471+
ni.dedupSharedDRAGpus(task, resourcesToTrackVector)
472+
469473
ni.UsedVector.Add(resourcesToTrackVector)
470474

471475
switch task.Status {
@@ -516,6 +520,10 @@ func (ni *NodeInfo) removeTaskResources(task *pod_info.PodInfo) {
516520
resourcesToTrackVector.Set(resource_info.GPUIndex, 0)
517521
}
518522

523+
// Mirror of dedupSharedDRAGpus: keep a shared physical DRA device in the
524+
// used vector as long as another pod on the node still references it.
525+
ni.releaseSharedDRAGpus(task, resourcesToTrackVector)
526+
519527
ni.UsedVector.Sub(resourcesToTrackVector)
520528

521529
switch task.Status {

0 commit comments

Comments
 (0)