Skip to content

Commit c18422b

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 c18422b

6 files changed

Lines changed: 374 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: |-
3+
Count a GPU shared by multiple pods through one DRA ResourceClaim once per node, preventing negative idle GPUs.
4+
custom:
5+
Issue: "1930"
6+
Author: TensorRaya

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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
current := resourcesToTrack.Get(resource_info.GPUIndex)
60+
if current <= 0 {
61+
// The task contributes no GPUs to the used vector (e.g. a resource
62+
// reservation task whose GPU index was zeroed). Tracking its devices
63+
// would both risk a negative deduction below and mask the reference
64+
// count of the real consuming pods, so leave the accounting untouched.
65+
return
66+
}
67+
68+
alreadyCounted := 0.0
69+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
70+
if ni.DRASharedDeviceRefCount[key] > 0 {
71+
// Another pod on this node already contributed this physical
72+
// device to the used vector: do not count it again.
73+
alreadyCounted++
74+
}
75+
ni.DRASharedDeviceRefCount[key]++
76+
}
77+
78+
if alreadyCounted > current {
79+
// Never deduct more than the task's own GPU contribution.
80+
alreadyCounted = current
81+
}
82+
if alreadyCounted > 0 {
83+
resourcesToTrack.Set(resource_info.GPUIndex, current-alreadyCounted)
84+
}
85+
}
86+
87+
// releaseSharedDRAGpus is the inverse of dedupSharedDRAGpus: it decrements the
88+
// per-device reference count and adds back the GPU count for devices that
89+
// remain referenced by other pods (and were therefore never subtracted on this
90+
// task's removal path). It must be called once per removeTaskResources.
91+
func (ni *NodeInfo) releaseSharedDRAGpus(task *pod_info.PodInfo, resourcesToTrack resource_info.ResourceVector) {
92+
current := resourcesToTrack.Get(resource_info.GPUIndex)
93+
if current <= 0 {
94+
// Mirror of dedupSharedDRAGpus: a task that contributed no GPUs never
95+
// incremented the reference count, so it must not decrement it here.
96+
return
97+
}
98+
99+
stillShared := 0.0
100+
for _, key := range ni.allocatedGPUDeviceKeys(task) {
101+
if ni.DRASharedDeviceRefCount[key] > 1 {
102+
// The device stays referenced by another pod after this removal:
103+
// it must remain in the used vector, so this task's removal must
104+
// not subtract it.
105+
stillShared++
106+
}
107+
if ni.DRASharedDeviceRefCount[key] > 0 {
108+
ni.DRASharedDeviceRefCount[key]--
109+
}
110+
if ni.DRASharedDeviceRefCount[key] == 0 {
111+
delete(ni.DRASharedDeviceRefCount, key)
112+
}
113+
}
114+
115+
if stillShared > current {
116+
// Never add back more than the task's own GPU contribution.
117+
stillShared = current
118+
}
119+
if stillShared > 0 {
120+
resourcesToTrack.Set(resource_info.GPUIndex, current-stillShared)
121+
}
122+
}
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)