|
| 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 | +} |
0 commit comments