Skip to content

Commit 16d8f82

Browse files
authored
fix(scheduler): Fixed numa accounting for non-int-cpu-request sidecars (#1982)
Signed-off-by: itsomri <omric@nvidia.com>
1 parent 800cbcc commit 16d8f82

3 files changed

Lines changed: 187 additions & 12 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Fixed
2+
body: |-
3+
NUMA plugin no longer counts non-integral container CPU toward NUMA alignment

pkg/scheduler/plugins/numa/numa_test.go

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,16 @@ func TestBuildNumaRequests(t *testing.T) {
250250
cpu := func(q string) v1.ResourceRequirements {
251251
return v1.ResourceRequirements{Requests: v1.ResourceList{"cpu": resource.MustParse(q)}}
252252
}
253-
pod := &v1.Pod{Spec: v1.PodSpec{
254-
InitContainers: []v1.Container{
255-
{Resources: cpu("10")}, // ordinary init: not a steady-state request
256-
{Resources: cpu("1"), RestartPolicy: &always}, // native sidecar: a steady-state request
253+
pod := &v1.Pod{
254+
Status: v1.PodStatus{QOSClass: v1.PodQOSGuaranteed}, // cpu aligns only for a Guaranteed pod
255+
Spec: v1.PodSpec{
256+
InitContainers: []v1.Container{
257+
{Resources: cpu("10")}, // ordinary init: not a steady-state request
258+
{Resources: cpu("1"), RestartPolicy: &always}, // native sidecar: a steady-state request
259+
},
260+
Containers: []v1.Container{{Resources: cpu("2")}, {Resources: cpu("2")}},
257261
},
258-
Containers: []v1.Container{{Resources: cpu("2")}, {Resources: cpu("2")}},
259-
}}
262+
}
260263
reqs := buildNumaRequests(pod, resource_info.NewResourceVectorMap())
261264
cores := func(v resource_info.ResourceVector) int64 { return int64(v.Get(resource_info.CPUIndex)) / 1000 }
262265

@@ -282,6 +285,122 @@ func TestBuildNumaRequests(t *testing.T) {
282285
})
283286
}
284287

288+
// TestBuildNumaRequestsNonIntegralCPU verifies the plugin charges NUMA zones only for the CPU the
289+
// kubelet actually pins. The CPU manager gives exclusive CPUs to whole-number requests in a
290+
// Guaranteed pod only (staticPolicy.guaranteedCPUs), so a sidecar requesting fractional CPU must
291+
// not inflate the request -- summing it would reject pods the kubelet admits.
292+
func TestBuildNumaRequestsNonIntegralCPU(t *testing.T) {
293+
always := v1.ContainerRestartPolicyAlways
294+
resources := func(cpu, mem string) v1.ResourceRequirements {
295+
return v1.ResourceRequirements{Requests: v1.ResourceList{
296+
"cpu": resource.MustParse(cpu), "memory": resource.MustParse(mem),
297+
}}
298+
}
299+
podWith := func(qos v1.PodQOSClass, containers ...v1.Container) *v1.Pod {
300+
return &v1.Pod{
301+
Status: v1.PodStatus{QOSClass: qos},
302+
Spec: v1.PodSpec{Containers: containers},
303+
}
304+
}
305+
milliCores := func(v resource_info.ResourceVector) int64 { return int64(v.Get(resource_info.CPUIndex)) }
306+
memory := func(v resource_info.ResourceVector) int64 {
307+
return int64(v.Get(resource_info.NewResourceVectorMap().GetIndex("memory")))
308+
}
309+
310+
main := v1.Container{Resources: resources("44", "16Gi")}
311+
fractionalMain := v1.Container{Resources: resources("700m", "16Gi")}
312+
fractionalSidecar := v1.Container{Resources: resources("4500m", "2Gi")}
313+
integralSidecar := v1.Container{Resources: resources("5", "2Gi")}
314+
315+
t.Run("fractional sidecar does not inflate the pod-scope cpu request", func(t *testing.T) {
316+
reqs := buildNumaRequests(podWith(v1.PodQOSGuaranteed, main, fractionalSidecar),
317+
resource_info.NewResourceVectorMap())
318+
concurrent, _ := reqs.forScope(node_info.TopologyScopePod)
319+
assert.Equal(t, int64(44000), milliCores(concurrent[0]), "only the main container's 44 integral cpus")
320+
assert.Equal(t, int64(18)<<30, memory(concurrent[0]),
321+
"memory is summed over every container: the memory manager applies no integrality filter")
322+
})
323+
324+
t.Run("integral sidecar is charged in full", func(t *testing.T) {
325+
reqs := buildNumaRequests(podWith(v1.PodQOSGuaranteed, main, integralSidecar),
326+
resource_info.NewResourceVectorMap())
327+
concurrent, _ := reqs.forScope(node_info.TopologyScopePod)
328+
assert.Equal(t, int64(49000), milliCores(concurrent[0]))
329+
})
330+
331+
t.Run("integral sidecar is charged when the main container is fractional", func(t *testing.T) {
332+
reqs := buildNumaRequests(podWith(v1.PodQOSGuaranteed, fractionalMain, integralSidecar),
333+
resource_info.NewResourceVectorMap())
334+
concurrent, _ := reqs.forScope(node_info.TopologyScopePod)
335+
assert.Equal(t, int64(5000), milliCores(concurrent[0]))
336+
337+
concurrent, _ = reqs.forScope(node_info.TopologyScopeContainer)
338+
assert.Zero(t, milliCores(concurrent[0]), "the main container stays in the shared cpu pool")
339+
assert.Equal(t, int64(5000), milliCores(concurrent[1]))
340+
})
341+
342+
t.Run("container scope zeroes only the fractional container's cpu", func(t *testing.T) {
343+
reqs := buildNumaRequests(podWith(v1.PodQOSGuaranteed, main, fractionalSidecar),
344+
resource_info.NewResourceVectorMap())
345+
concurrent, _ := reqs.forScope(node_info.TopologyScopeContainer)
346+
assert.Equal(t, int64(44000), milliCores(concurrent[0]))
347+
assert.Zero(t, milliCores(concurrent[1]), "the sidecar stays in the shared cpu pool")
348+
assert.Equal(t, int64(2)<<30, memory(concurrent[1]),
349+
"the sidecar is still a memory alignment unit of its own")
350+
})
351+
352+
t.Run("non-Guaranteed pod aligns no cpu at all", func(t *testing.T) {
353+
reqs := buildNumaRequests(podWith(v1.PodQOSBurstable, main, fractionalSidecar),
354+
resource_info.NewResourceVectorMap())
355+
concurrent, _ := reqs.forScope(node_info.TopologyScopePod)
356+
assert.Zero(t, milliCores(concurrent[0]))
357+
})
358+
359+
t.Run("fractional init containers are zeroed in both roles", func(t *testing.T) {
360+
pod := &v1.Pod{
361+
Status: v1.PodStatus{QOSClass: v1.PodQOSGuaranteed},
362+
Spec: v1.PodSpec{
363+
InitContainers: []v1.Container{
364+
{Resources: resources("10500m", "1Gi")},
365+
{Resources: resources("1500m", "1Gi"), RestartPolicy: &always},
366+
},
367+
Containers: []v1.Container{main},
368+
},
369+
}
370+
reqs := buildNumaRequests(pod, resource_info.NewResourceVectorMap())
371+
concurrent, serial := reqs.forScope(node_info.TopologyScopeContainer)
372+
assert.Zero(t, milliCores(serial[0]), "ordinary init")
373+
assert.Zero(t, milliCores(concurrent[0]), "native sidecar")
374+
375+
podConcurrent, _ := reqs.forScope(node_info.TopologyScopePod)
376+
assert.Equal(t, int64(44000), milliCores(podConcurrent[0]),
377+
"neither init container contributes to the pod-scope peak")
378+
})
379+
}
380+
381+
// TestFractionalSidecarAdmits is the unit-test form of examples/numa/sidecar-cpu-inflation: a 44-cpu
382+
// main container with a 4500m sidecar must admit (the kubelet asks for 44 exclusive cpus, which fit
383+
// one zone), while rounding that sidecar to an integral 5 cpus must reject (49 fits no zone).
384+
func TestFractionalSidecarAdmits(t *testing.T) {
385+
pp, _, node := wiredPlugin(numaTopology(node_info.TopologyPolicySingleNUMANode, node_info.TopologyScopePod,
386+
numaZone("node-0", map[string]string{gpu: "4", "cpu": "47"}),
387+
numaZone("node-1", map[string]string{gpu: "4", "cpu": "48"}),
388+
))
389+
390+
task := func(sidecarCPU string) *pod_info.PodInfo {
391+
t := makeGuaranteedTask("task-"+sidecarCPU, map[string]string{gpu: "1", "cpu": "44"})
392+
t.Pod.Spec.Containers = append(t.Pod.Spec.Containers, v1.Container{
393+
Resources: v1.ResourceRequirements{Requests: v1.ResourceList{"cpu": resource.MustParse(sidecarCPU)}},
394+
})
395+
return t
396+
}
397+
398+
assert.True(t, pp.allocatable(task("4500m"), node),
399+
"a fractional sidecar is not pinned, so the pod still needs only 44 cpus in one zone")
400+
assert.False(t, pp.allocatable(task("5"), node),
401+
"an integral sidecar is pinned, pushing the pod to 49 cpus, which no zone has")
402+
}
403+
285404
// TestPredicateOrdinaryInitContainer verifies an ordinary init container is checked for
286405
// alignability on its own (rejected if it cannot fit a zone) but is not accumulated into the
287406
// concurrent app containers' headroom.

pkg/scheduler/plugins/numa/requests.go

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package numa
55

66
import (
7+
"math"
8+
79
v1 "k8s.io/api/core/v1"
810
resourcehelper "k8s.io/component-helpers/resource"
911

@@ -46,23 +48,74 @@ func (pp *numaPlugin) numaRequestsFor(task *pod_info.PodInfo, vectorMap *resourc
4648
}
4749

4850
func buildNumaRequests(pod *v1.Pod, vectorMap *resource_info.ResourceVectorMap) *podNumaRequests {
51+
cpuIdx := vectorMap.GetIndex(v1.ResourceCPU)
52+
4953
podReq := resourcehelper.PodRequests(pod, resourcehelper.PodResourcesOptions{})
50-
reqs := &podNumaRequests{
51-
podScope: []resource_info.ResourceVector{resource_info.NewResourceVectorFromResourceList(podReq, vectorMap)},
52-
}
54+
podVec := resource_info.NewResourceVectorFromResourceList(podReq, vectorMap)
55+
setCPUMilli(podVec, cpuIdx, podGuaranteedCPUMilli(pod))
56+
reqs := &podNumaRequests{podScope: []resource_info.ResourceVector{podVec}}
5357

5458
for i := range pod.Spec.InitContainers {
5559
c := &pod.Spec.InitContainers[i]
5660
vec := resource_info.NewResourceVectorFromResourceList(c.Resources.Requests, vectorMap)
57-
if c.RestartPolicy != nil && *c.RestartPolicy == v1.ContainerRestartPolicyAlways {
61+
setCPUMilli(vec, cpuIdx, guaranteedCPUMilli(pod, c))
62+
if isNativeSidecar(c) {
5863
reqs.concurrent = append(reqs.concurrent, vec)
5964
} else {
6065
reqs.serial = append(reqs.serial, vec)
6166
}
6267
}
6368
for i := range pod.Spec.Containers {
64-
reqs.concurrent = append(reqs.concurrent,
65-
resource_info.NewResourceVectorFromResourceList(pod.Spec.Containers[i].Resources.Requests, vectorMap))
69+
c := &pod.Spec.Containers[i]
70+
vec := resource_info.NewResourceVectorFromResourceList(c.Resources.Requests, vectorMap)
71+
setCPUMilli(vec, cpuIdx, guaranteedCPUMilli(pod, c))
72+
reqs.concurrent = append(reqs.concurrent, vec)
6673
}
6774
return reqs
6875
}
76+
77+
func isNativeSidecar(c *v1.Container) bool {
78+
return c.RestartPolicy != nil && *c.RestartPolicy == v1.ContainerRestartPolicyAlways
79+
}
80+
81+
func setCPUMilli(vec resource_info.ResourceVector, cpuIdx int, milli float64) {
82+
if cpuIdx >= 0 && cpuIdx < len(vec) {
83+
vec[cpuIdx] = milli
84+
}
85+
}
86+
87+
// guaranteedCPUMilli mirrors the kubelet's staticPolicy.guaranteedCPUs: a container is allocated
88+
// exclusive, NUMA-aligned CPUs only in a Guaranteed pod and only for a whole number of CPUs. A
89+
// container requesting fractional CPU stays in the shared pool and constrains no NUMA zone, so
90+
// summing its request (as resourcehelper.PodRequests does) would over-constrain the pod.
91+
func guaranteedCPUMilli(pod *v1.Pod, c *v1.Container) float64 {
92+
if pod.Status.QOSClass != v1.PodQOSGuaranteed {
93+
return 0
94+
}
95+
q := c.Resources.Requests[v1.ResourceCPU]
96+
if q.Value()*1000 != q.MilliValue() {
97+
return 0
98+
}
99+
return float64(q.MilliValue())
100+
}
101+
102+
// podGuaranteedCPUMilli mirrors the kubelet's staticPolicy.podGuaranteedCPUs: the init-peak vs
103+
// long-running-sum shape of PodRequests, but with each container's non-aligned CPU zeroed.
104+
func podGuaranteedCPUMilli(pod *v1.Pod) float64 {
105+
initPeak, restartableInit := 0.0, 0.0
106+
for i := range pod.Spec.InitContainers {
107+
c := &pod.Spec.InitContainers[i]
108+
cpu := guaranteedCPUMilli(pod, c)
109+
if isNativeSidecar(c) {
110+
restartableInit += cpu
111+
} else if restartableInit+cpu > initPeak {
112+
initPeak = restartableInit + cpu
113+
}
114+
}
115+
116+
longRunning := restartableInit
117+
for i := range pod.Spec.Containers {
118+
longRunning += guaranteedCPUMilli(pod, &pod.Spec.Containers[i])
119+
}
120+
return math.Max(longRunning, initPeak)
121+
}

0 commit comments

Comments
 (0)