From 047437e0b2354b867734da26090f4bd3ef150fc9 Mon Sep 17 00:00:00 2001 From: Antti Kervinen Date: Tue, 21 Jul 2026 15:19:28 +0300 Subject: [PATCH 1/2] cpuclass: hint all good CPU sets instead of only the best Satisfying CPU affinity to multiple preferences encoded in preferCloseToDevice requires more flexibility than provided in CPU class preference. For example, a container requires PCT HP (high priority) CPUs and CPU affinity to a GPU device. There are enough HP CPUs available in both sockets. Because socket 0 happens to have fewer HP CPUs available, cpuclass hint considers it worse choice than socket 1 with more HP CPUs. However, the GPU may be connected to socket 0. As a result, preferCloseToDevices consists of a GPU-affinity hint to all CPUs on socket 0, and HP CPU affinity hint to HP CPUs on socket 1. The latter hint will be ignored, in which order they ever happen to be. This commit changes cpuclass (HP CPUs) hints so that all good-enough CPU sets will be published. Given that cpuclass hints are applied after device (GPU) hints, above example works as follows. GPU-affinity hints all CPUs on socket 0. Next, best HP CPU affinity would hint HP CPUs on socket 1. As the intersection hinted CPUs is empty, cputree.go allocator ignores this hint. Finally, second-best HP CPU affinity hints HP CPUs on socket 0. Now intersection with already taken hints is not empty, so this hint is applied, and the container will get HP CPUs near the GPU. Technically, this commit changes CPU class AllocationHints to contain best-first-ordered list of good-enough CPU sets as alternatives for CPU allocators to prefer. Signed-off-by: Antti Kervinen --- .../balloons/policy/balloons-policy.go | 8 +- cmd/plugins/balloons/policy/cpuclass_test.go | 12 +- pkg/resmgr/cpuclass/cpuclass.go | 34 +++-- pkg/resmgr/cpuclass/internal/pct/pct.go | 131 ++++++++++-------- pkg/resmgr/cpuclass/internal/pct/pct_test.go | 28 ++-- pkg/resmgr/cpuclass/internal/types/types.go | 9 +- 6 files changed, 126 insertions(+), 96 deletions(-) diff --git a/cmd/plugins/balloons/policy/balloons-policy.go b/cmd/plugins/balloons/policy/balloons-policy.go index 26eafc724..0165526f3 100644 --- a/cmd/plugins/balloons/policy/balloons-policy.go +++ b/cmd/plugins/balloons/policy/balloons-policy.go @@ -2151,15 +2151,15 @@ func mergeCpuClassHints(opts *cpuTreeAllocatorOptions, provider cpuClassHints, i hints := provider.Hints(intent) for i, pref := range hints.Prefer { name := fmt.Sprintf("%spref_%d_%s", cpuClassHintDevPrefix, i, pref.Name) - opts.virtDevCpusets[name] = []cpuset.CPUSet{pref.Cpus} + opts.virtDevCpusets[name] = pref.Cpus opts.preferCloseToDevices = append(opts.preferCloseToDevices, name) - log.Debugf("cpuclass hint: prefer %q -> %s", name, pref.Cpus) + log.Debugf("cpuclass hint: prefer %q -> %v", name, pref.Cpus) } for i, av := range hints.Avoid { name := fmt.Sprintf("%savoid_%d_%s", cpuClassHintDevPrefix, i, av.Name) - opts.virtDevCpusets[name] = []cpuset.CPUSet{av.Cpus} + opts.virtDevCpusets[name] = av.Cpus opts.preferFarFromDevices = append(opts.preferFarFromDevices, name) - log.Debugf("cpuclass hint: avoid %q -> %s", name, av.Cpus) + log.Debugf("cpuclass hint: avoid %q -> %v", name, av.Cpus) } } diff --git a/cmd/plugins/balloons/policy/cpuclass_test.go b/cmd/plugins/balloons/policy/cpuclass_test.go index c2aa63345..e86ed4dcd 100644 --- a/cmd/plugins/balloons/policy/cpuclass_test.go +++ b/cmd/plugins/balloons/policy/cpuclass_test.go @@ -74,23 +74,23 @@ func TestMergeCpuClassHintsNoAccumulation(t *testing.T) { script: []cpuclass.AllocationHints{ // Round 1: one prefer (A), one avoid. { - Prefer: []cpuclass.CpuPreference{{Name: "hp-reserve", Cpus: cpusA}}, - Avoid: []cpuclass.CpuPreference{{Name: "lp-clos", Cpus: cpusAvoid}}, + Prefer: []cpuclass.CpuPreference{{Name: "hp-reserve", Cpus: []cpuset.CPUSet{cpusA}}}, + Avoid: []cpuclass.CpuPreference{{Name: "lp-clos", Cpus: []cpuset.CPUSet{cpusAvoid}}}, }, // Round 2: two prefers (A, B) - different name at index 1 // so the slot-0 name stays stable, slot-1 is new. { Prefer: []cpuclass.CpuPreference{ - {Name: "hp-reserve", Cpus: cpusA}, - {Name: "extra", Cpus: cpusB}, + {Name: "hp-reserve", Cpus: []cpuset.CPUSet{cpusA}}, + {Name: "extra", Cpus: []cpuset.CPUSet{cpusB}}, }, - Avoid: []cpuclass.CpuPreference{{Name: "lp-clos", Cpus: cpusAvoid}}, + Avoid: []cpuclass.CpuPreference{{Name: "lp-clos", Cpus: []cpuset.CPUSet{cpusAvoid}}}, }, // Round 3: name at slot 0 CHANGES to C - without proper // cleanup the stale "__cls_pref_0_hp-reserve" map key from // rounds 1+2 would survive into round 3. { - Prefer: []cpuclass.CpuPreference{{Name: "third", Cpus: cpusC}}, + Prefer: []cpuclass.CpuPreference{{Name: "third", Cpus: []cpuset.CPUSet{cpusC}}}, Avoid: nil, }, }, diff --git a/pkg/resmgr/cpuclass/cpuclass.go b/pkg/resmgr/cpuclass/cpuclass.go index 21da445aa..66ee6b22d 100644 --- a/pkg/resmgr/cpuclass/cpuclass.go +++ b/pkg/resmgr/cpuclass/cpuclass.go @@ -265,22 +265,30 @@ func (h *Handler) Shutdown() error { } // intersectHints returns a copy of hints with every CpuPreference -// constrained to the given bound. Empty preferences are dropped. +// candidate set constrained to the given bound. Empty candidate sets +// are dropped; a preference is dropped only when all of its candidate +// sets become empty. Candidate order is preserved. func intersectHints(hints AllocationHints, bound cpuset.CPUSet) AllocationHints { out := AllocationHints{} - for _, p := range hints.Prefer { - s := p.Cpus.Intersection(bound) - if s.IsEmpty() { - continue - } - out.Prefer = append(out.Prefer, CpuPreference{Name: p.Name, Cpus: s}) - } - for _, p := range hints.Avoid { - s := p.Cpus.Intersection(bound) - if s.IsEmpty() { - continue + clip := func(prefs []CpuPreference) []CpuPreference { + var res []CpuPreference + for _, p := range prefs { + sets := make([]cpuset.CPUSet, 0, len(p.Cpus)) + for _, c := range p.Cpus { + s := c.Intersection(bound) + if s.IsEmpty() { + continue + } + sets = append(sets, s) + } + if len(sets) == 0 { + continue + } + res = append(res, CpuPreference{Name: p.Name, Cpus: sets}) } - out.Avoid = append(out.Avoid, CpuPreference{Name: p.Name, Cpus: s}) + return res } + out.Prefer = clip(hints.Prefer) + out.Avoid = clip(hints.Avoid) return out } diff --git a/pkg/resmgr/cpuclass/internal/pct/pct.go b/pkg/resmgr/cpuclass/internal/pct/pct.go index 5e6e3f30c..36bc173c7 100644 --- a/pkg/resmgr/cpuclass/internal/pct/pct.go +++ b/pkg/resmgr/cpuclass/internal/pct/pct.go @@ -696,31 +696,37 @@ func (a *Allocator) hpInUseCpus() cpuset.CPUSet { return out } -// hpReserveCpus returns the CPU set the upcoming HP allocation +// hpReserveCpus returns the CPU sets the upcoming HP allocation // should prefer, computed with punit-granular HP-room accounting: // // room(punit) = MaxHpCpus(punit) - len(hpUsed[punit] \ excludeBln) // +// The result is a priority-ordered list of disjoint candidate sets, +// from most to least preferred. One should honor the first possible +// candidate and ignore others. +// // Selection follows a strict tier order: // -// - Tier A (single-punit win): the punit with the largest -// non-zero room and at least requested free CPUs. Returns the -// free CPUs of that punit. +// - Tier A (single-punit wins): every punit with non-zero room and +// at least requested free CPUs, ordered by descending room, then +// descending free-CPU count, then ascending package/punit ID. +// Each punit's free CPUs form one candidate set. // - Tier B (same-package union): when no single punit can host -// `requested` HP CPUs but some package's punits jointly can, -// return the union of free CPUs across that package's punits. -// The picked package is the one with the largest aggregate -// room; ties broken by largest aggregate free-CPU count. +// `requested` HP CPUs but some packages' punits jointly can, +// each such package's union of free CPUs forms one candidate +// set, ordered by descending aggregate room, then descending +// aggregate free-CPU count, then ascending package ID. // - Tier C (cross-package): never. Steering HP work across // sockets defeats the turbo gains it would obtain, because // cross-socket data traffic typically dominates per-core // frequency benefits. // +// Tiers are never mixed, so all returned candidate sets are disjoint. // When `requested` is 0 the function falls back to Tier A only -- -// pick the punit with the most HP room and at least one free CPU. -// Returns the empty set when no punit/package satisfies any tier -// or no free CPUs remain after Allowed-intersection; the caller -// then falls back to topology-only placement. +// every punit with HP room and at least one free CPU. +// Returns nil when no punit/package satisfies any tier or no free +// CPUs remain after Allowed-intersection; the caller then falls back +// to topology-only placement. // // - free: free CPUs to consider for placement. // - excludeBln: CPUs to exclude from HP-room accounting (the @@ -729,15 +735,15 @@ func (a *Allocator) hpInUseCpus() cpuset.CPUSet { // - requested: number of CPUs the upcoming allocation wants. // 0 means "unknown" (initial priming before the count is // known); Tier A is used. -func (a *Allocator) hpReserveCpus(free cpuset.CPUSet, excludeBln cpuset.CPUSet, requested int) cpuset.CPUSet { +func (a *Allocator) hpReserveCpus(free cpuset.CPUSet, excludeBln cpuset.CPUSet, requested int) []cpuset.CPUSet { if !a.hpHintsActive() { - return cpuset.New() + return nil } if a.allowed.Size() > 0 { free = free.Intersection(a.allowed) } if free.IsEmpty() { - return cpuset.New() + return nil } type punitState struct { @@ -767,17 +773,16 @@ func (a *Allocator) hpReserveCpus(free cpuset.CPUSet, excludeBln cpuset.CPUSet, states[i].room = room } if !anyKnown { - return cpuset.New() + return nil } - // Tier A: best single punit that satisfies the request. + // Tier A: every single punit that can host the request, best + // first. Punit free sets are disjoint by construction. need := requested if need < 1 { need = 1 } - bestIdx := -1 - bestRoom := 0 - bestFree := -1 + tierA := make([]int, 0, len(a.punits)) for i := range a.punits { s := states[i] if s.free.IsEmpty() || s.room <= 0 { @@ -788,20 +793,34 @@ func (a *Allocator) hpReserveCpus(free cpuset.CPUSet, excludeBln cpuset.CPUSet, if s.free.Size() < need || s.room < need { continue } - if s.room > bestRoom || (s.room == bestRoom && s.free.Size() > bestFree) { - bestIdx = i - bestRoom = s.room - bestFree = s.free.Size() - } + tierA = append(tierA, i) } - if bestIdx >= 0 { - log.Debugf("pct: hpReserveCpus tier=A punit=%d/%d room=%d free=%s", - a.punits[bestIdx].PkgID, a.punits[bestIdx].PunitID, bestRoom, states[bestIdx].free) - return states[bestIdx].free + if len(tierA) > 0 { + sort.Slice(tierA, func(x, y int) bool { + ix, iy := tierA[x], tierA[y] + if states[ix].room != states[iy].room { + return states[ix].room > states[iy].room + } + if states[ix].free.Size() != states[iy].free.Size() { + return states[ix].free.Size() > states[iy].free.Size() + } + if a.punits[ix].PkgID != a.punits[iy].PkgID { + return a.punits[ix].PkgID < a.punits[iy].PkgID + } + return a.punits[ix].PunitID < a.punits[iy].PunitID + }) + reserve := make([]cpuset.CPUSet, 0, len(tierA)) + for _, i := range tierA { + reserve = append(reserve, states[i].free) + log.Debugf("pct: hpReserveCpus tier=A punit=%d/%d room=%d free=%s", + a.punits[i].PkgID, a.punits[i].PunitID, states[i].room, states[i].free) + } + return reserve } - // Tier B: aggregate per package; pick the package whose - // punits together have the most room (and free CPUs). + // Tier B: aggregate per package; every package whose punits + // together can host the request, best first. Package unions + // are disjoint by construction. if requested > 0 { type pkgAgg struct { room int @@ -824,37 +843,36 @@ func (a *Allocator) hpReserveCpus(free cpuset.CPUSet, excludeBln cpuset.CPUSet, pkgIDs := make([]int, 0, len(agg)) for id, e := range agg { e.freeN = e.free.Size() + if e.room < requested || e.freeN < requested { + continue + } pkgIDs = append(pkgIDs, id) } - sort.Ints(pkgIDs) // deterministic tie-break order - bestPkg := -1 - bestPkgRoom := 0 - bestPkgFree := -1 - for _, id := range pkgIDs { - e := agg[id] - if e.room < requested { - continue + sort.Slice(pkgIDs, func(x, y int) bool { + ex, ey := agg[pkgIDs[x]], agg[pkgIDs[y]] + if ex.room != ey.room { + return ex.room > ey.room } - if e.freeN < requested { - continue + if ex.freeN != ey.freeN { + return ex.freeN > ey.freeN } - if e.room > bestPkgRoom || (e.room == bestPkgRoom && e.freeN > bestPkgFree) { - bestPkg = id - bestPkgRoom = e.room - bestPkgFree = e.freeN + return pkgIDs[x] < pkgIDs[y] + }) + if len(pkgIDs) > 0 { + reserve := make([]cpuset.CPUSet, 0, len(pkgIDs)) + for _, id := range pkgIDs { + reserve = append(reserve, agg[id].free) + log.Debugf("pct: hpReserveCpus tier=B pkg=%d room=%d free=%s", + id, agg[id].room, agg[id].free) } - } - if bestPkg >= 0 { - log.Debugf("pct: hpReserveCpus tier=B pkg=%d room=%d free=%s", - bestPkg, bestPkgRoom, agg[bestPkg].free) - return agg[bestPkg].free + return reserve } } // Tier C is never taken: do not hint across packages. log.Debugf("pct: hpReserveCpus tier=none (no punit or package has %d HP room with %d free CPUs)", requested, free.Size()) - return cpuset.New() + return nil } // classClosID returns the CLOS ID that the named cpuClass maps to, @@ -892,8 +910,9 @@ func virtDevSstClosHint(closID int) string { // - Class has an explicit CLOS plan (assoc-only or managed): Prefer // CLOS-member CPUs. // - Class is currently classified HP: Prefer hpReserveCpus -// (best-fit punit; same-package union as fallback), and also -// CLOS-member CPUs. No cross-package hint is ever emitted. +// (per-punit candidates best first, same-package unions as +// fallback), and also CLOS-member CPUs. No cross-package hint is +// ever emitted. // - Class is not HP and at least one HP class exists: Avoid // hpInUseCpus (punits currently hosting HP work). func (a *Allocator) Hints(intent types.AllocationIntent) types.AllocationHints { @@ -907,14 +926,14 @@ func (a *Allocator) Hints(intent types.AllocationIntent) types.AllocationHints { if !closCpus.IsEmpty() { out.Prefer = append(out.Prefer, types.CpuPreference{ Name: virtDevSstClosHint(closID), - Cpus: closCpus, + Cpus: []cpuset.CPUSet{closCpus}, }) } } if a.classIsHighPriority(intent.ClassName) { reserve := a.hpReserveCpus(intent.FreeCpus, intent.CurrentCpus, intent.RequestedCount) - if !reserve.IsEmpty() { + if len(reserve) > 0 { out.Prefer = append(out.Prefer, types.CpuPreference{ Name: virtDevSstHpReserveHint, Cpus: reserve, @@ -928,7 +947,7 @@ func (a *Allocator) Hints(intent types.AllocationIntent) types.AllocationHints { if !inUse.IsEmpty() { out.Avoid = append(out.Avoid, types.CpuPreference{ Name: virtDevSstHpInUseHint, - Cpus: inUse, + Cpus: []cpuset.CPUSet{inUse}, }) } } diff --git a/pkg/resmgr/cpuclass/internal/pct/pct_test.go b/pkg/resmgr/cpuclass/internal/pct/pct_test.go index 61df8bd37..007b19730 100644 --- a/pkg/resmgr/cpuclass/internal/pct/pct_test.go +++ b/pkg/resmgr/cpuclass/internal/pct/pct_test.go @@ -326,8 +326,8 @@ func TestPctHintsAssocOnlyPreferClosCpus(t *testing.T) { t.Errorf("Prefer[0].Name = %q, want %q", got.Prefer[0].Name, virtDevSstClosHint(1)) } want := cpuset.MustParse("2-3") - if !got.Prefer[0].Cpus.Equals(want) { - t.Errorf("Prefer[0].Cpus = %s, want %s", got.Prefer[0].Cpus, want) + if !got.Prefer[0].Cpus[0].Equals(want) { + t.Errorf("Prefer[0].Cpus = %v, want %s", got.Prefer[0].Cpus, want) } if len(got.Avoid) != 0 { t.Errorf("assoc-only mode must not emit Avoid hints: %+v", got.Avoid) @@ -382,8 +382,8 @@ func TestPctHintsHighPriorityReserveAndClosCpus(t *testing.T) { t.Errorf("Prefer[1].Name = %q, want %q", got.Prefer[1].Name, virtDevSstHpReserveHint) } wantReserve := cpuset.MustParse("4-7") - if !got.Prefer[1].Cpus.Equals(wantReserve) { - t.Errorf("HP reserve = %s, want %s (largest-room package)", got.Prefer[1].Cpus, wantReserve) + if !got.Prefer[1].Cpus[0].Equals(wantReserve) { + t.Errorf("HP reserve = %v, want %s (largest-room package)", got.Prefer[1].Cpus, wantReserve) } // HP-class hints must NOT carry an Avoid (HP picks first). if len(got.Avoid) != 0 { @@ -439,8 +439,8 @@ func TestPctHintsManagedNonHpAvoidsHpInUse(t *testing.T) { t.Errorf("Avoid[0].Name = %q, want %q", got.Avoid[0].Name, virtDevSstHpInUseHint) } wantAvoid := cpuset.MustParse("0-3") // entire pkg0 - if !got.Avoid[0].Cpus.Equals(wantAvoid) { - t.Errorf("Avoid[0].Cpus = %s, want %s (pkg0 == HP-in-use package)", got.Avoid[0].Cpus, wantAvoid) + if !got.Avoid[0].Cpus[0].Equals(wantAvoid) { + t.Errorf("Avoid[0].Cpus = %v, want %s (pkg0 == HP-in-use package)", got.Avoid[0].Cpus, wantAvoid) } } @@ -481,15 +481,15 @@ func TestPctHintsAllowedBoundsResults(t *testing.T) { if len(got.Prefer) == 0 { t.Fatalf("Prefer empty, want at least closCpus hint") } - if !got.Prefer[0].Cpus.Equals(cpuset.MustParse("0")) { - t.Errorf("Prefer[0].Cpus = %s, want {0} (cpu 4 outside allowed)", got.Prefer[0].Cpus) + if !got.Prefer[0].Cpus[0].Equals(cpuset.MustParse("0")) { + t.Errorf("Prefer[0].Cpus = %v, want {0} (cpu 4 outside allowed)", got.Prefer[0].Cpus) } // HP reserve must come from a package whose free CPUs are // inside allowed; only pkg0 qualifies. if len(got.Prefer) >= 2 { want := cpuset.MustParse("1-3") - if !got.Prefer[1].Cpus.Equals(want) { - t.Errorf("HP reserve = %s, want %s (pkg0 free cpus inside allowed)", got.Prefer[1].Cpus, want) + if !got.Prefer[1].Cpus[0].Equals(want) { + t.Errorf("HP reserve = %v, want %s (pkg0 free cpus inside allowed)", got.Prefer[1].Cpus, want) } } } @@ -556,7 +556,7 @@ func TestPctHints_HpRoomTierAPunitWins(t *testing.T) { var reserve cpuset.CPUSet for _, p := range got.Prefer { if p.Name == virtDevSstHpReserveHint { - reserve = p.Cpus + reserve = p.Cpus[0] } } if reserve.IsEmpty() { @@ -607,7 +607,7 @@ func TestPctHints_HpRoomTierBSamePackage(t *testing.T) { var reserve cpuset.CPUSet for _, p := range got.Prefer { if p.Name == virtDevSstHpReserveHint { - reserve = p.Cpus + reserve = p.Cpus[0] } } if reserve.IsEmpty() { @@ -693,8 +693,8 @@ func TestPctHints_HpInUseIsPunitGranular(t *testing.T) { } // Must be punit-0 (cpus 0-3) ONLY, not all of pkg0 (0-7). want := cpuset.MustParse("0-3") - if !got.Avoid[0].Cpus.Equals(want) { - t.Errorf("Avoid = %s, want %s (punit-0 only, not full pkg0)", got.Avoid[0].Cpus, want) + if !got.Avoid[0].Cpus[0].Equals(want) { + t.Errorf("Avoid = %v, want %s (punit-0 only, not full pkg0)", got.Avoid[0].Cpus, want) } } diff --git a/pkg/resmgr/cpuclass/internal/types/types.go b/pkg/resmgr/cpuclass/internal/types/types.go index b554beb63..842a39d63 100644 --- a/pkg/resmgr/cpuclass/internal/types/types.go +++ b/pkg/resmgr/cpuclass/internal/types/types.go @@ -71,11 +71,14 @@ type AllocationIntent struct { RequestedCount int } -// CpuPreference is a named CPU set carrying a single placement -// preference (prefer or avoid depending on the slice it appears in). +// CpuPreference is a named, priority-ordered list of candidate CPU +// sets carrying a single placement preference (prefer or avoid +// depending on the slice it appears in). Cpus lists candidate sets +// ordered from most to least preferred: an allocation should honor +// the first candidate it can and ignore others. type CpuPreference struct { Name string - Cpus cpuset.CPUSet + Cpus []cpuset.CPUSet } // AllocationHints carries technology-agnostic placement preferences From 9aaa59c6ce00f122383847f5ad7933299330ab4f Mon Sep 17 00:00:00 2001 From: Antti Kervinen Date: Wed, 22 Jul 2026 17:44:17 +0300 Subject: [PATCH 2/2] balloons: do not hint non-free CPUs This change cleans up device hints. Not giving a hint about a CLOS with insufficient CPUs enables taking a hint from HP CPUs reserve. Signed-off-by: Antti Kervinen --- pkg/resmgr/cpuclass/internal/pct/pct.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/resmgr/cpuclass/internal/pct/pct.go b/pkg/resmgr/cpuclass/internal/pct/pct.go index 36bc173c7..10fc88f34 100644 --- a/pkg/resmgr/cpuclass/internal/pct/pct.go +++ b/pkg/resmgr/cpuclass/internal/pct/pct.go @@ -923,10 +923,11 @@ func (a *Allocator) Hints(intent types.AllocationIntent) types.AllocationHints { if closID, ok := a.classClosID(intent.ClassName); ok { closCpus := a.closCpus(closID) - if !closCpus.IsEmpty() { + freeClosCpus := closCpus.Intersection(intent.FreeCpus) + if !freeClosCpus.IsEmpty() && freeClosCpus.Size() >= intent.RequestedCount { out.Prefer = append(out.Prefer, types.CpuPreference{ Name: virtDevSstClosHint(closID), - Cpus: []cpuset.CPUSet{closCpus}, + Cpus: []cpuset.CPUSet{freeClosCpus}, }) } }