Skip to content

Commit 5c17e7b

Browse files
committed
node: cpumgr: metrics: add uncore cache alignment metrics
add missing metric about uncore / L3 / Last-Level cache alignment, plus its e2e tests. Exposing uncore alignment requires a bit of refactoring in the static policy implementation because, differently from full PCPUs alignment and NUMA alignment, can't be easily and safely inferred by construction. The main reason for this is that uncore cache alignment is preferred, not mandatory, thus the cpu allocator can legally use cross-uncore allocation. Because of that, the final cpuset union step can create a final cpuset which is not uncore-aligned even though all its parts are uncore-aligned. The safest way seems thus to run just a final uncore-alignment check once the final cpuset is computed. Signed-off-by: Francesco Romani <[email protected]>
1 parent 9cfe186 commit 5c17e7b

File tree

8 files changed

+361
-24
lines changed

8 files changed

+361
-24
lines changed

pkg/kubelet/cm/cpumanager/policy_static.go

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ func (p *staticPolicy) Allocate(s state.State, pod *v1.Pod, container *v1.Contai
330330
}
331331
return
332332
}
333+
// TODO: move in updateMetricsOnAllocate
333334
if p.options.FullPhysicalCPUsOnly {
334335
// increment only if we know we allocate aligned resources
335336
metrics.ContainerAlignedComputeResources.WithLabelValues(metrics.AlignScopeContainer, metrics.AlignedPhysicalCPU).Inc()
@@ -369,8 +370,8 @@ func (p *staticPolicy) Allocate(s state.State, pod *v1.Pod, container *v1.Contai
369370
}
370371
}
371372
}
372-
if cpuset, ok := s.GetCPUSet(string(pod.UID), container.Name); ok {
373-
p.updateCPUsToReuse(pod, container, cpuset)
373+
if cset, ok := s.GetCPUSet(string(pod.UID), container.Name); ok {
374+
p.updateCPUsToReuse(pod, container, cset)
374375
klog.InfoS("Static policy: container already present in state, skipping", "pod", klog.KObj(pod), "containerName", container.Name)
375376
return nil
376377
}
@@ -380,17 +381,17 @@ func (p *staticPolicy) Allocate(s state.State, pod *v1.Pod, container *v1.Contai
380381
klog.InfoS("Topology Affinity", "pod", klog.KObj(pod), "containerName", container.Name, "affinity", hint)
381382

382383
// Allocate CPUs according to the NUMA affinity contained in the hint.
383-
cpuset, err := p.allocateCPUs(s, numCPUs, hint.NUMANodeAffinity, p.cpusToReuse[string(pod.UID)])
384+
cpuAllocation, err := p.allocateCPUs(s, numCPUs, hint.NUMANodeAffinity, p.cpusToReuse[string(pod.UID)])
384385
if err != nil {
385386
klog.ErrorS(err, "Unable to allocate CPUs", "pod", klog.KObj(pod), "containerName", container.Name, "numCPUs", numCPUs)
386387
return err
387388
}
388389

389-
s.SetCPUSet(string(pod.UID), container.Name, cpuset)
390-
p.updateCPUsToReuse(pod, container, cpuset)
391-
p.updateMetricsOnAllocate(cpuset)
390+
s.SetCPUSet(string(pod.UID), container.Name, cpuAllocation.CPUs)
391+
p.updateCPUsToReuse(pod, container, cpuAllocation.CPUs)
392+
p.updateMetricsOnAllocate(cpuAllocation)
392393

393-
klog.V(4).InfoS("Allocated exclusive CPUs", "pod", klog.KObj(pod), "containerName", container.Name, "cpuset", cpuset)
394+
klog.V(4).InfoS("Allocated exclusive CPUs", "pod", klog.KObj(pod), "containerName", container.Name, "cpuset", cpuAllocation.CPUs.String())
394395
return nil
395396
}
396397

@@ -420,13 +421,13 @@ func (p *staticPolicy) RemoveContainer(s state.State, podUID string, containerNa
420421
return nil
421422
}
422423

423-
func (p *staticPolicy) allocateCPUs(s state.State, numCPUs int, numaAffinity bitmask.BitMask, reusableCPUs cpuset.CPUSet) (cpuset.CPUSet, error) {
424+
func (p *staticPolicy) allocateCPUs(s state.State, numCPUs int, numaAffinity bitmask.BitMask, reusableCPUs cpuset.CPUSet) (topology.Allocation, error) {
424425
klog.InfoS("AllocateCPUs", "numCPUs", numCPUs, "socket", numaAffinity)
425426

426427
allocatableCPUs := p.GetAvailableCPUs(s).Union(reusableCPUs)
427428

428429
// If there are aligned CPUs in numaAffinity, attempt to take those first.
429-
result := cpuset.New()
430+
result := topology.EmptyAllocation()
430431
if numaAffinity != nil {
431432
alignedCPUs := p.getAlignedCPUs(numaAffinity, allocatableCPUs)
432433

@@ -435,25 +436,26 @@ func (p *staticPolicy) allocateCPUs(s state.State, numCPUs int, numaAffinity bit
435436
numAlignedToAlloc = numCPUs
436437
}
437438

438-
alignedCPUs, err := p.takeByTopology(alignedCPUs, numAlignedToAlloc)
439+
allocatedCPUs, err := p.takeByTopology(alignedCPUs, numAlignedToAlloc)
439440
if err != nil {
440-
return cpuset.New(), err
441+
return topology.EmptyAllocation(), err
441442
}
442443

443-
result = result.Union(alignedCPUs)
444+
result.CPUs = result.CPUs.Union(allocatedCPUs)
444445
}
445446

446447
// Get any remaining CPUs from what's leftover after attempting to grab aligned ones.
447-
remainingCPUs, err := p.takeByTopology(allocatableCPUs.Difference(result), numCPUs-result.Size())
448+
remainingCPUs, err := p.takeByTopology(allocatableCPUs.Difference(result.CPUs), numCPUs-result.CPUs.Size())
448449
if err != nil {
449-
return cpuset.New(), err
450+
return topology.EmptyAllocation(), err
450451
}
451-
result = result.Union(remainingCPUs)
452+
result.CPUs = result.CPUs.Union(remainingCPUs)
453+
result.Aligned = p.topology.CheckAlignment(result.CPUs)
452454

453455
// Remove allocated CPUs from the shared CPUSet.
454-
s.SetDefaultCPUSet(s.GetDefaultCPUSet().Difference(result))
456+
s.SetDefaultCPUSet(s.GetDefaultCPUSet().Difference(result.CPUs))
455457

456-
klog.InfoS("AllocateCPUs", "result", result)
458+
klog.InfoS("AllocateCPUs", "result", result.String())
457459
return result, nil
458460
}
459461

@@ -755,12 +757,17 @@ func (p *staticPolicy) initializeMetrics(s state.State) {
755757
metrics.CPUManagerSharedPoolSizeMilliCores.Set(float64(p.GetAvailableCPUs(s).Size() * 1000))
756758
metrics.CPUManagerExclusiveCPUsAllocationCount.Set(float64(countExclusiveCPUs(s)))
757759
metrics.ContainerAlignedComputeResourcesFailure.WithLabelValues(metrics.AlignScopeContainer, metrics.AlignedPhysicalCPU).Add(0) // ensure the value exists
760+
metrics.ContainerAlignedComputeResources.WithLabelValues(metrics.AlignScopeContainer, metrics.AlignedPhysicalCPU).Add(0) // ensure the value exists
761+
metrics.ContainerAlignedComputeResources.WithLabelValues(metrics.AlignScopeContainer, metrics.AlignedUncoreCache).Add(0) // ensure the value exists
758762
}
759763

760-
func (p *staticPolicy) updateMetricsOnAllocate(cset cpuset.CPUSet) {
761-
ncpus := cset.Size()
764+
func (p *staticPolicy) updateMetricsOnAllocate(cpuAlloc topology.Allocation) {
765+
ncpus := cpuAlloc.CPUs.Size()
762766
metrics.CPUManagerExclusiveCPUsAllocationCount.Add(float64(ncpus))
763767
metrics.CPUManagerSharedPoolSizeMilliCores.Add(float64(-ncpus * 1000))
768+
if cpuAlloc.Aligned.UncoreCache {
769+
metrics.ContainerAlignedComputeResources.WithLabelValues(metrics.AlignScopeContainer, metrics.AlignedUncoreCache).Inc()
770+
}
764771
}
765772

766773
func (p *staticPolicy) updateMetricsOnRelease(cset cpuset.CPUSet) {

pkg/kubelet/cm/cpumanager/policy_static_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -966,16 +966,16 @@ func TestTopologyAwareAllocateCPUs(t *testing.T) {
966966
continue
967967
}
968968

969-
cset, err := policy.allocateCPUs(st, tc.numRequested, tc.socketMask, cpuset.New())
969+
cpuAlloc, err := policy.allocateCPUs(st, tc.numRequested, tc.socketMask, cpuset.New())
970970
if err != nil {
971971
t.Errorf("StaticPolicy allocateCPUs() error (%v). expected CPUSet %v not error %v",
972972
tc.description, tc.expCSet, err)
973973
continue
974974
}
975975

976-
if !tc.expCSet.Equals(cset) {
976+
if !tc.expCSet.Equals(cpuAlloc.CPUs) {
977977
t.Errorf("StaticPolicy allocateCPUs() error (%v). expected CPUSet %v but got %v",
978-
tc.description, tc.expCSet, cset)
978+
tc.description, tc.expCSet, cpuAlloc.CPUs)
979979
}
980980
}
981981
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package topology
18+
19+
import (
20+
"fmt"
21+
22+
"k8s.io/utils/cpuset"
23+
)
24+
25+
// Alignment is metadata about a cpuset allocation
26+
type Alignment struct {
27+
// UncoreCache is true if all the CPUs are uncore-cache aligned,
28+
// IOW if they all share the same Uncore cache block.
29+
// If the allocated CPU count is greater than a Uncore Group size,
30+
// CPUs can't be uncore-aligned; otherwise, they are.
31+
// This flag tracks alignment, not interference or lack thereof.
32+
UncoreCache bool
33+
}
34+
35+
func (ca Alignment) String() string {
36+
return fmt.Sprintf("aligned=<uncore:%v>", ca.UncoreCache)
37+
}
38+
39+
// Allocation represents a CPU set plus alignment metadata
40+
type Allocation struct {
41+
CPUs cpuset.CPUSet
42+
Aligned Alignment
43+
}
44+
45+
func (ca Allocation) String() string {
46+
return ca.CPUs.String() + " " + ca.Aligned.String()
47+
}
48+
49+
// EmptyAllocation returns a new zero-valued CPU allocation. Please note that
50+
// a empty cpuset is aligned according to every possible way we can consider
51+
func EmptyAllocation() Allocation {
52+
return Allocation{
53+
CPUs: cpuset.New(),
54+
Aligned: Alignment{
55+
UncoreCache: true,
56+
},
57+
}
58+
}
59+
60+
func isAlignedAtUncoreCache(topo *CPUTopology, cpuList ...int) bool {
61+
if len(cpuList) <= 1 {
62+
return true
63+
}
64+
reference, ok := topo.CPUDetails[cpuList[0]]
65+
if !ok {
66+
return false
67+
}
68+
for _, cpu := range cpuList[1:] {
69+
info, ok := topo.CPUDetails[cpu]
70+
if !ok {
71+
return false
72+
}
73+
if info.UncoreCacheID != reference.UncoreCacheID {
74+
return false
75+
}
76+
}
77+
return true
78+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package topology
18+
19+
import (
20+
"reflect"
21+
"testing"
22+
23+
"k8s.io/utils/cpuset"
24+
)
25+
26+
func TestNewAlignment(t *testing.T) {
27+
topo := &CPUTopology{
28+
NumCPUs: 32,
29+
NumSockets: 1,
30+
NumCores: 32,
31+
NumNUMANodes: 1,
32+
NumUncoreCache: 8,
33+
CPUDetails: map[int]CPUInfo{
34+
0: {CoreID: 0, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 0},
35+
1: {CoreID: 1, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 0},
36+
2: {CoreID: 2, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 0},
37+
3: {CoreID: 3, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 0},
38+
4: {CoreID: 4, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 1},
39+
5: {CoreID: 5, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 1},
40+
6: {CoreID: 6, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 1},
41+
7: {CoreID: 7, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 1},
42+
8: {CoreID: 8, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 2},
43+
9: {CoreID: 9, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 2},
44+
10: {CoreID: 10, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 2},
45+
11: {CoreID: 11, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 2},
46+
12: {CoreID: 12, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 3},
47+
13: {CoreID: 13, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 3},
48+
14: {CoreID: 14, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 3},
49+
15: {CoreID: 15, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 3},
50+
16: {CoreID: 16, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 4},
51+
17: {CoreID: 17, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 4},
52+
18: {CoreID: 18, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 4},
53+
19: {CoreID: 19, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 4},
54+
20: {CoreID: 20, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 5},
55+
21: {CoreID: 21, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 5},
56+
22: {CoreID: 22, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 5},
57+
23: {CoreID: 23, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 5},
58+
24: {CoreID: 24, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 6},
59+
25: {CoreID: 25, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 6},
60+
26: {CoreID: 26, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 6},
61+
27: {CoreID: 27, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 6},
62+
28: {CoreID: 28, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 7},
63+
29: {CoreID: 29, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 7},
64+
30: {CoreID: 30, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 7},
65+
31: {CoreID: 31, SocketID: 0, NUMANodeID: 0, UncoreCacheID: 7},
66+
},
67+
}
68+
69+
tests := []struct {
70+
name string
71+
topo *CPUTopology
72+
cpus cpuset.CPUSet
73+
want Alignment
74+
}{{
75+
name: "empty cpuset",
76+
topo: topo,
77+
cpus: cpuset.New(),
78+
want: Alignment{
79+
UncoreCache: true,
80+
},
81+
}, {
82+
name: "single random CPU",
83+
topo: topo,
84+
cpus: cpuset.New(11), // any single id is fine, no special meaning
85+
want: Alignment{
86+
UncoreCache: true,
87+
},
88+
}, {
89+
name: "less CPUs than a uncore cache group",
90+
topo: topo,
91+
cpus: cpuset.New(29, 30, 31), // random cpus as long as they belong to the same uncore cache
92+
want: Alignment{
93+
UncoreCache: true,
94+
},
95+
}, {
96+
name: "enough CPUs to fill a uncore cache group",
97+
topo: topo,
98+
cpus: cpuset.New(8, 9, 10, 11), // random cpus as long as they belong to the same uncore cache
99+
want: Alignment{
100+
UncoreCache: true,
101+
},
102+
}, {
103+
name: "more CPUs than a full a uncore cache group",
104+
topo: topo,
105+
cpus: cpuset.New(9, 10, 11, 23), // random cpus as long as they belong to the same uncore cache
106+
want: Alignment{
107+
UncoreCache: false,
108+
},
109+
}, {
110+
name: "enough CPUs to exactly fill multiple uncore cache groups",
111+
topo: topo,
112+
cpus: cpuset.New(8, 9, 10, 11, 16, 17, 18, 19), // random cpus as long as they belong to the same uncore cache
113+
want: Alignment{
114+
UncoreCache: false,
115+
},
116+
}}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
got := tt.topo.CheckAlignment(tt.cpus)
121+
if !reflect.DeepEqual(got, tt.want) {
122+
t.Errorf("AlignmentFromCPUSet() = %v, want %v", got, tt.want)
123+
}
124+
})
125+
}
126+
}

pkg/kubelet/cm/cpumanager/topology/topology.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ func (topo *CPUTopology) CPUNUMANodeID(cpu int) (int, error) {
101101
return info.NUMANodeID, nil
102102
}
103103

104+
// CheckAlignment returns alignment information for the given cpuset in
105+
// the context of the current CPU topology
106+
func (topo *CPUTopology) CheckAlignment(cpus cpuset.CPUSet) Alignment {
107+
cpuList := cpus.UnsortedList()
108+
return Alignment{
109+
UncoreCache: isAlignedAtUncoreCache(topo, cpuList...),
110+
}
111+
}
112+
104113
// CPUInfo contains the NUMA, socket, UncoreCache and core IDs associated with a CPU.
105114
type CPUInfo struct {
106115
NUMANodeID int

pkg/kubelet/metrics/metrics.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ const (
150150

151151
AlignedPhysicalCPU = "physical_cpu"
152152
AlignedNUMANode = "numa_node"
153+
AlignedUncoreCache = "uncore_cache"
153154

154155
// Metrics to track kubelet admission rejections.
155156
AdmissionRejectionsTotalKey = "admission_rejections_total"

0 commit comments

Comments
 (0)