Skip to content

Commit 2fac247

Browse files
Raya SolanoTensorRaya
authored andcommitted
fix(scheduler): count a shared DRA GPU device once per node
When one physical GPU is shared by several pods through a single ResourceClaim (status.reservedFor lists more than one consumer, as with MPS or time-slicing), every consuming pod carries the same allocated device. The node accounting added that device to the used vector once per pod, so a 1-GPU node shared by two pods reported used: 2, capacity: 1. IdleVector then went negative and MaxNodeResourcesPredicate rejected the node for every task, even ones requesting no GPU at all, which showed up as a confusing "didn't have enough resources: GPUs" on a node that clearly had one. Keep a per-device reference count on the node, keyed by driver/pool/device from the allocation result, and skip a device that another pod on the node already contributed. Removal mirrors this: the device stays counted until the last consumer leaves. Exclusive claims and non-DRA GPUs are untouched. Reservation pods are a special case worth calling out: addTaskResources zeroes their GPU index before the dedup runs, so counting their shared devices would subtract from zero and drive the used count negative. Both the dedup and release paths now leave a zero-GPU task's accounting alone. Signed-off-by: Raya Solano <raya@mbinf.de>
1 parent f23d1e4 commit 2fac247

5 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixed
2+
body: Count a GPU shared by multiple pods through one DRA ResourceClaim once per node, preventing negative idle GPUs.
3+
time: 2026-07-17T23:34:28.999387137Z
4+
custom:
5+
Author: TensorRaya
6+
Issue: "1930"
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
"github.com/kai-scheduler/KAI-scheduler/pkg/common/resources"
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+
// draDeviceKey uniquely identifies a physical DRA device on the node.
15+
func draDeviceKey(result resourceapi.DeviceRequestAllocationResult) string {
16+
return result.Driver + "/" + result.Pool + "/" + result.Device
17+
}
18+
19+
// allocatedGPUDeviceKeys returns the keys of all GPU devices allocated to the
20+
// task via DRA ResourceClaims. Non-GPU devices are ignored: they are not part
21+
// of the GPU accounting that this dedup protects.
22+
func (ni *NodeInfo) allocatedGPUDeviceKeys(task *pod_info.PodInfo) []string {
23+
var keys []string
24+
for _, claimAllocation := range task.ResourceClaimInfo {
25+
if claimAllocation == nil || claimAllocation.Allocation == nil {
26+
continue
27+
}
28+
for _, result := range claimAllocation.Allocation.Devices.Results {
29+
if !resources.IsGPUDeviceClass(result.Driver) {
30+
continue
31+
}
32+
keys = append(keys, draDeviceKey(result))
33+
}
34+
}
35+
return keys
36+
}
37+
38+
// sharedDRAGpuDiscount returns the number of GPU devices the task requests via
39+
// DRA claims that are already counted on this node for other pods. A task that
40+
// shares an allocated device with a running pod does not need additional GPU
41+
// capacity for that device.
42+
func (ni *NodeInfo) sharedDRAGpuDiscount(task *pod_info.PodInfo) float64 {
43+
discount := 0.0
44+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
45+
if ni.DRASharedDeviceRefCount[key] > 0 {
46+
discount++
47+
}
48+
}
49+
return discount
50+
}
51+
52+
// dedupSharedDRAGpus removes from resourcesToTrack the GPU count that would
53+
// double-count physical DRA devices already referenced by other pods on the
54+
// node. It also updates the node's per-device reference count. It must be
55+
// called once per addTaskResources, before the vector is added to UsedVector.
56+
func (ni *NodeInfo) dedupSharedDRAGpus(task *pod_info.PodInfo, resourcesToTrack resource_info.ResourceVector) {
57+
current := resourcesToTrack.Get(resource_info.GPUIndex)
58+
if current <= 0 {
59+
// The task contributes no GPUs to the used vector (e.g. a resource
60+
// reservation task whose GPU index was zeroed). Tracking its devices
61+
// would both risk a negative deduction below and mask the reference
62+
// count of the real consuming pods, so leave the accounting untouched.
63+
return
64+
}
65+
66+
alreadyCounted := 0.0
67+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
68+
if ni.DRASharedDeviceRefCount[key] > 0 {
69+
// Another pod on this node already contributed this physical
70+
// device to the used vector: do not count it again.
71+
alreadyCounted++
72+
}
73+
ni.DRASharedDeviceRefCount[key]++
74+
}
75+
76+
if alreadyCounted > current {
77+
// Never deduct more than the task's own GPU contribution.
78+
alreadyCounted = current
79+
}
80+
if alreadyCounted > 0 {
81+
resourcesToTrack.Set(resource_info.GPUIndex, current-alreadyCounted)
82+
}
83+
}
84+
85+
// releaseSharedDRAGpus is the inverse of dedupSharedDRAGpus: it decrements the
86+
// per-device reference count and adds back the GPU count for devices that
87+
// remain referenced by other pods (and were therefore never subtracted on this
88+
// task's removal path). It must be called once per removeTaskResources.
89+
func (ni *NodeInfo) releaseSharedDRAGpus(task *pod_info.PodInfo, resourcesToTrack resource_info.ResourceVector) {
90+
current := resourcesToTrack.Get(resource_info.GPUIndex)
91+
if current <= 0 {
92+
// Mirror of dedupSharedDRAGpus: a task that contributed no GPUs never
93+
// incremented the reference count, so it must not decrement it here.
94+
return
95+
}
96+
97+
stillShared := 0.0
98+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
99+
if ni.DRASharedDeviceRefCount[key] > 1 {
100+
// The device stays referenced by another pod after this removal:
101+
// it must remain in the used vector, so this task's removal must
102+
// not subtract it.
103+
stillShared++
104+
}
105+
if ni.DRASharedDeviceRefCount[key] > 0 {
106+
ni.DRASharedDeviceRefCount[key]--
107+
}
108+
if ni.DRASharedDeviceRefCount[key] == 0 {
109+
delete(ni.DRASharedDeviceRefCount, key)
110+
}
111+
}
112+
113+
if stillShared > current {
114+
// Never add back more than the task's own GPU contribution.
115+
stillShared = current
116+
}
117+
if stillShared > 0 {
118+
resourcesToTrack.Set(resource_info.GPUIndex, current-stillShared)
119+
}
120+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
// draReservationPod builds a resource-reservation pod that references the given
156+
// claim. addTaskResources zeroes such a pod's GPU index, so it must not affect
157+
// the shared-device GPU accounting at all.
158+
func draReservationPod(name, namespace, nodeName, claimName string) *v1.Pod {
159+
pod := common_info.BuildPod(namespace, name, nodeName, v1.PodRunning,
160+
common_info.BuildResourceList("1000m", "1G"), []metav1.OwnerReference{},
161+
map[string]string{
162+
commonconstants.AppLabelName: "kai-resource-reservation",
163+
}, map[string]string{
164+
pod_info.ReceivedResourceTypeAnnotationName: string(pod_info.ReceivedTypeRegular),
165+
commonconstants.PodGroupAnnotationForPod: common_info.FakePogGroupId,
166+
})
167+
pod.Spec.ResourceClaims = []v1.PodResourceClaim{
168+
{Name: claimName, ResourceClaimName: ptr.To(claimName)},
169+
}
170+
return pod
171+
}
172+
173+
// TestAddTask_DistinctDRADevicesEachCounted guards against over-dedup: two pods
174+
// on two different physical GPUs (own claim each) must both be counted.
175+
func TestAddTask_DistinctDRADevicesEachCounted(t *testing.T) {
176+
ni, vectorMap := newGPUNodeInfo(t, "nyx", "2", 2, 0)
177+
178+
claimA := sharedGPUClaim("coder-claim", "vllm", gpuDeviceClass, "nyx", "gpu-4")
179+
claimB := sharedGPUClaim("gemma-claim", "vllm", gpuDeviceClass, "nyx", "gpu-6")
180+
podA := draConsumerPod("coder", "vllm", "nyx", "coder-claim")
181+
podB := draConsumerPod("gemma", "vllm", "nyx", "gemma-claim")
182+
taskA := pod_info.NewTaskInfo(podA, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claimA}})
183+
taskB := pod_info.NewTaskInfo(podB, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claimB}})
184+
185+
assert.NoError(t, ni.AddTask(taskA))
186+
assert.NoError(t, ni.AddTask(taskB))
187+
assert.Equal(t, 2.0, ni.UsedVector.Get(resource_info.GPUIndex),
188+
"two distinct physical devices must both be counted")
189+
}
190+
191+
// TestAddRemoveTask_ReservationPodDoesNotCorruptSharedDRAAccounting guards the
192+
// dedup against resource-reservation tasks. addTaskResources zeroes a
193+
// reservation pod's GPU index, so it contributes 0 GPUs; if the dedup still
194+
// tracked that pod's shared device it would subtract a device from a 0 vector
195+
// (driving UsedVector negative and corrupting node capacity) and inflate the
196+
// reference count, masking the real consumers. A reservation pod referencing
197+
// the same shared claim as a real consumer must leave the GPU used count at 1
198+
// on add and on remove, in any order.
199+
func TestAddRemoveTask_ReservationPodDoesNotCorruptSharedDRAAccounting(t *testing.T) {
200+
ni, vectorMap := newGPUNodeInfo(t, "atlas", "1", 2, 2)
201+
202+
claim := sharedGPUClaim("voice-atlas-shared", "voice-pipeline", gpuDeviceClass, "atlas", "gpu-0")
203+
consumer := draConsumerPod("voice-tts", "voice-pipeline", "atlas", "voice-atlas-shared")
204+
reservation := draReservationPod("kai-resource-reservation-abc", "voice-pipeline", "atlas", "voice-atlas-shared")
205+
consumerTask := pod_info.NewTaskInfo(consumer, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claim}})
206+
reservationTask := pod_info.NewTaskInfo(reservation, vectorMap, pod_info.TaskInfoOptions{DraPodClaims: []*resourceapi.ResourceClaim{claim}})
207+
208+
assert.NoError(t, ni.AddTask(consumerTask))
209+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex),
210+
"real consumer of the shared device must be counted once")
211+
212+
assert.NoError(t, ni.AddTask(reservationTask))
213+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex),
214+
"reservation pod contributes no GPU and must not change the used count")
215+
216+
idleGPUs, _ := ni.GetSumOfIdleGPUs()
217+
assert.Equal(t, 0.0, idleGPUs, "idle GPUs must stay 0, never negative")
218+
219+
assert.NoError(t, ni.RemoveTask(reservationTask))
220+
assert.Equal(t, 1.0, ni.UsedVector.Get(resource_info.GPUIndex),
221+
"removing the reservation pod must leave the real consumer's device counted")
222+
223+
assert.NoError(t, ni.RemoveTask(consumerTask))
224+
assert.Equal(t, 0.0, ni.UsedVector.Get(resource_info.GPUIndex),
225+
"removing the last real consumer must release the shared device")
226+
}

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
}

0 commit comments

Comments
 (0)