Skip to content

Commit be7eda2

Browse files
committed
[AA][HI]: perfprof creator: autosizing implementation
Implement reserved cpu (aka infra+control plane) sizing using a the linear programming optimization (gonum/optimize). The core idea is to model the constraints and let the optimization package compute the desired target. These changes where AI-Assisted (hence the AA tag), then largely amended by a human (hence the HI tag - Human Intervention). The initial penalty cost structure was suggested by google Gemini 2.5 flash, and then amended by human intervention. Assisted-by: Google Gemini Assisted-by-model: gemini-2.5-flash Signed-off-by: Francesco Romani <fromani@redhat.com>
1 parent 310c413 commit be7eda2

File tree

436 files changed

+95279
-5
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

436 files changed

+95279
-5
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/prometheus/client_golang v1.21.1
2929
github.com/spf13/cobra v1.9.1
3030
github.com/spf13/pflag v1.0.6
31+
gonum.org/v1/gonum v0.16.0
3132
gopkg.in/fsnotify.v1 v1.4.7
3233
gopkg.in/ini.v1 v1.67.0
3334
gopkg.in/yaml.v2 v2.4.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
824824
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
825825
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
826826
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
827+
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
828+
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
827829
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
828830
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
829831
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package autosize
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"log"
7+
"math"
8+
9+
"gonum.org/v1/gonum/optimize"
10+
11+
"github.com/openshift/cluster-node-tuning-operator/pkg/performanceprofile/profilecreator"
12+
)
13+
14+
// Assumptions:
15+
// 1. All the machines in the node pool have identical HW specs and need identical sizing.
16+
// 2. We cannot distinguyish betwee infra/OS CPU requirements and control plane CPU requirement.
17+
// We will conflate the two costs in the latter.
18+
//
19+
// Definitions:
20+
// x_c: CPUs for the control plane - includes x_i: CPUs for OS/Infra
21+
// x_w: CPUs for the workload
22+
// Tc: Total available CPUs (includes OS/Infra
23+
//
24+
// Hard Constraints:
25+
// x_c, x_w are integers because we need to dedicate full cores
26+
// x_c, x_w >= 0
27+
// x_c + x_w <= Tc
28+
// x_c >= req(x_w) // control plane and infra cost is a function of the expected workload
29+
//
30+
// Objective:
31+
// We want to maximize x_w, or, equivalently, minimize x_c
32+
33+
const (
34+
defaultPenaltyWeight float64 = 100.0
35+
defaultReservedRatioInitial float64 = 0.0625 // 1/16. determined empirically. Use only as initial value.
36+
defaultReservedRatioMax float64 = 0.25 // 1/4. determined empirically. This is the practical upper bound.
37+
defaultControlPlaneWorkloadCoreRatio float64 = 0.075 // TODO: how much control plane/infra power do we need to support the workload?
38+
)
39+
40+
var (
41+
ErrUnderallocatedControlPlane = errors.New("not enough CPUs for control plane")
42+
ErrOverallocatedControlPlane = errors.New("too many CPUs for control plane")
43+
ErrInconsistentAllocation = errors.New("inconsistent CPus allocation")
44+
)
45+
46+
type Env struct {
47+
Log *log.Logger
48+
}
49+
50+
func DefaultEnv() Env {
51+
return Env{
52+
Log: profilecreator.GetAlertSink(),
53+
}
54+
}
55+
56+
type Params struct {
57+
OfflinedCPUCount int
58+
UserLevelNetworking bool
59+
MachineData *profilecreator.GHWHandler
60+
// cached vars
61+
totalCPUs int
62+
smtLevel int
63+
}
64+
65+
func (p Params) String() string {
66+
return fmt.Sprintf("cpus=%d offline=%v smtLevel=%v", p.totalCPUs, p.OfflinedCPUCount, p.smtLevel)
67+
}
68+
69+
func setupMachineData(p *Params) error {
70+
var err error
71+
72+
cpus, err := p.MachineData.CPU()
73+
if err != nil {
74+
return err
75+
}
76+
77+
p.totalCPUs = int(cpus.TotalHardwareThreads)
78+
// NOTE: this assumes all cores are equal, but it's a limitation also shared by GHW. CPUs with P/E cores will be misrepresented.
79+
p.smtLevel = int(cpus.TotalHardwareThreads / cpus.TotalCores)
80+
return nil
81+
}
82+
83+
func (p Params) TotalCPUs() int {
84+
return p.totalCPUs
85+
}
86+
87+
func (p Params) SMTLevel() int {
88+
return p.smtLevel
89+
}
90+
91+
func (p Params) DefaultControlPlaneCores() int {
92+
// intentionally overallocate to have a safe baseline
93+
Tc := p.totalCPUs
94+
return int(math.Round(float64(Tc) * defaultReservedRatioInitial)) // TODO handle SMT
95+
}
96+
97+
// Get x_c, x_w as initial hardcoded value. Subject to optimization
98+
func (p Params) DefaultAllocation() Values {
99+
Tc := p.totalCPUs
100+
x_c := p.DefaultControlPlaneCores()
101+
return Values{
102+
ReservedCPUCount: x_c,
103+
IsolatedCPUCount: Tc - x_c,
104+
}
105+
}
106+
107+
func (p Params) initialValue() []float64 {
108+
vals := p.DefaultAllocation()
109+
return []float64{
110+
float64(vals.ReservedCPUCount), // x_c
111+
float64(vals.IsolatedCPUCount), // x_w
112+
}
113+
}
114+
115+
func (p Params) controlPlaneRequirement(x_w float64) float64 {
116+
R := defaultControlPlaneWorkloadCoreRatio
117+
if p.UserLevelNetworking {
118+
R = 0.0
119+
}
120+
// TODO: the most obvious relationship is for kernel level networking.
121+
// We start with a linear relationship because its simplicity.
122+
return float64(p.DefaultControlPlaneCores()) + R*x_w
123+
}
124+
125+
type Score struct {
126+
Cost float64 // the lower the better
127+
}
128+
129+
func (sc Score) String() string {
130+
val := -sc.Cost // positive values are easier to grasp
131+
return fmt.Sprintf("optimization result: %.3f (higher is better)", val)
132+
}
133+
134+
type Values struct {
135+
// we intentionally compute the recommended cpu count, not precise allocation, because
136+
// this is better done by other packages. We may expose the precise allocation as hint
137+
// or for reference purposes in the future
138+
ReservedCPUCount int
139+
IsolatedCPUCount int
140+
}
141+
142+
func (vals Values) String() string {
143+
return fmt.Sprintf("reserved=%v/isolated=%v", vals.ReservedCPUCount, vals.IsolatedCPUCount)
144+
}
145+
146+
// gonum doesn't support bounds yet so we have to make this an explicit step
147+
// https://github.com/gonum/gonum/issues/1725
148+
func Validate(params Params, vals Values) error {
149+
Tc := params.TotalCPUs()
150+
if vals.ReservedCPUCount < 1 { // TODO handle SMT
151+
return ErrUnderallocatedControlPlane
152+
}
153+
if vals.ReservedCPUCount > int(math.Round((float64(Tc) * defaultReservedRatioMax))) { // works, but likely unacceptable
154+
return ErrOverallocatedControlPlane
155+
}
156+
if Tc != vals.ReservedCPUCount+vals.IsolatedCPUCount {
157+
return ErrInconsistentAllocation
158+
}
159+
return nil
160+
}
161+
162+
// Objective function to minimize.
163+
// x[0] is x_c
164+
// x[1] is x_w
165+
func objective(p Params, x []float64) float64 {
166+
xc := x[0]
167+
xw := x[1]
168+
169+
// Our original objective is to maximize xw, so we minimize -xw
170+
target := -xw
171+
172+
// gonum doesn't support bounds yet so we have to use penalties:
173+
// https://github.com/gonum/gonum/issues/1725
174+
175+
// Hard Constraints
176+
var hardPenalty float64
177+
// Don't exceed total CPUs
178+
hardPenalty += defaultPenaltyWeight * math.Pow(math.Max(0, xc+xw-float64(p.TotalCPUs())), 2)
179+
180+
// Meet the control plane/infra requirement to avoid the workload to starve
181+
hardPenalty += defaultPenaltyWeight * math.Pow(math.Max(0, p.controlPlaneRequirement(xw)-xc), 2)
182+
183+
// Must use positive CPU values (since gonum/optimize doesn't have simple bounds for all solvers)
184+
hardPenalty += defaultPenaltyWeight * (math.Pow(math.Max(0, -xc), 2) + math.Pow(math.Max(0, -xw), 2))
185+
186+
// Allocate in multiples of SMT level (usually 2) -- TODO: should be soft?
187+
hardPenalty += defaultPenaltyWeight * (math.Pow(math.Max(0, -float64(int(math.Round(xc))%p.SMTLevel())), 2))
188+
189+
return target + hardPenalty
190+
}
191+
192+
func Compute(env Env, params Params) (Values, Score, error) {
193+
err := setupMachineData(&params)
194+
if err != nil {
195+
env.Log.Printf("Optimization failed: %v", err)
196+
return params.DefaultAllocation(), Score{}, err
197+
}
198+
199+
problem := optimize.Problem{
200+
Func: func(x []float64) float64 {
201+
return objective(params, x)
202+
},
203+
}
204+
205+
settings := &optimize.Settings{
206+
MajorIterations: 99,
207+
}
208+
209+
env.Log.Printf("Optimization start. Default allocation: %v", params.DefaultAllocation().String())
210+
env.Log.Printf("Optimization start. Params: %v", params.String())
211+
212+
result, err := optimize.Minimize(problem, params.initialValue(), settings, &optimize.NelderMead{})
213+
if err != nil {
214+
env.Log.Printf("Optimization failed: %v", err)
215+
return params.DefaultAllocation(), Score{}, err
216+
}
217+
218+
totCPUs := params.TotalCPUs()
219+
score := Score{Cost: result.F}
220+
x_w := int(math.Round(result.Location.X[1]))
221+
vals := Values{
222+
IsolatedCPUCount: x_w,
223+
ReservedCPUCount: totCPUs - x_w, // we can use x_c, but we just leverage invariants
224+
}
225+
226+
if err := Validate(params, vals); err != nil {
227+
env.Log.Printf("Optimization invalid: %v", err)
228+
return params.DefaultAllocation(), Score{}, err
229+
}
230+
231+
env.Log.Printf("Optimization done. Score: %v %s totalCPUs=%d", score.String(), vals.String(), totCPUs)
232+
return vals, score, nil
233+
}

pkg/performanceprofile/profilecreator/cmd/root.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
machineconfigv1 "github.com/openshift/api/machineconfiguration/v1"
3838
performancev2 "github.com/openshift/cluster-node-tuning-operator/pkg/apis/performanceprofile/v2"
3939
"github.com/openshift/cluster-node-tuning-operator/pkg/performanceprofile/profilecreator"
40+
"github.com/openshift/cluster-node-tuning-operator/pkg/performanceprofile/profilecreator/autosize"
4041
"github.com/openshift/cluster-node-tuning-operator/pkg/performanceprofile/profilecreator/cmd/hypershift"
4142
"github.com/openshift/cluster-node-tuning-operator/pkg/performanceprofile/profilecreator/serialize"
4243
"github.com/openshift/cluster-node-tuning-operator/pkg/performanceprofile/profilecreator/toleration"
@@ -116,10 +117,10 @@ func NewRootCommand() *cobra.Command {
116117
pcArgs := &ProfileCreatorArgs{
117118
UserLevelNetworking: ptr.To(false),
118119
PerPodPowerManagement: ptr.To(false),
120+
Autosize: ptr.To(false),
119121
}
120122

121123
var requiredFlags = []string{
122-
"reserved-cpu-count",
123124
"rt-kernel",
124125
"must-gather-dir-path",
125126
}
@@ -164,10 +165,26 @@ func NewRootCommand() *cobra.Command {
164165
if err != nil {
165166
return fmt.Errorf("targeted nodes differ: %w", err)
166167
}
168+
169+
sizing := autosize.Values{
170+
ReservedCPUCount: pcArgs.ReservedCPUCount,
171+
}
172+
if isAutosizeEnabled(pcArgs) {
173+
params := autosize.Params{
174+
OfflinedCPUCount: pcArgs.OfflinedCPUCount,
175+
UserLevelNetworking: (pcArgs.UserLevelNetworking != nil && *pcArgs.UserLevelNetworking),
176+
MachineData: nodesHandlers[0], // assume all nodes equal, pick the easiest
177+
}
178+
sizing, _, err = autosize.Compute(autosize.DefaultEnv(), params)
179+
if err != nil {
180+
return fmt.Errorf("failed to autosize the cluster values: %v", err)
181+
}
182+
}
183+
167184
// We make sure that the matched Nodes are the same
168185
// Assumption here is moving forward matchedNodes[0] is representative of how all the nodes are
169186
// same from hardware topology point of view
170-
profileData, err := makeProfileDataFrom(nodesHandlers[0], pcArgs)
187+
profileData, err := makeProfileDataFrom(nodesHandlers[0], pcArgs, sizing)
171188
if err != nil {
172189
return fmt.Errorf("failed to make profile data from node handler: %w", err)
173190
}
@@ -218,6 +235,9 @@ func validateProfileCreatorFlags(pcArgs *ProfileCreatorArgs) error {
218235
if pcArgs.MCPName != "" && pcArgs.NodePoolName != "" {
219236
return fmt.Errorf("--mcp-name and --node-pool-name options cannot be used together")
220237
}
238+
if !isAutosizeEnabled(pcArgs) && pcArgs.ReservedCPUCount == 0 {
239+
return fmt.Errorf("--reserved-cpu-count need to be set and greater than zero if autosizing (--autosize) is disabled")
240+
}
221241
if pcArgs.NodePoolName == "" {
222242
// NodePoolName is an alias of MCPName
223243
pcArgs.NodePoolName = pcArgs.MCPName
@@ -299,12 +319,13 @@ func makeClusterData(mustGatherDirPath string, createForHypershift bool) (Cluste
299319
return clusterData, nil
300320
}
301321

302-
func makeProfileDataFrom(nodeHandler *profilecreator.GHWHandler, args *ProfileCreatorArgs) (*ProfileData, error) {
322+
func makeProfileDataFrom(nodeHandler *profilecreator.GHWHandler, args *ProfileCreatorArgs, sizing autosize.Values) (*ProfileData, error) {
303323
systemInfo, err := nodeHandler.GatherSystemInfo()
304324
if err != nil {
305325
return nil, fmt.Errorf("failed to compute get system information: %v", err)
306326
}
307-
reservedCPUs, isolatedCPUs, offlinedCPUs, err := profilecreator.CalculateCPUSets(systemInfo, args.ReservedCPUCount, args.OfflinedCPUCount, args.SplitReservedCPUsAcrossNUMA, args.DisableHT, args.PowerConsumptionMode == ultraLowLatency)
327+
328+
reservedCPUs, isolatedCPUs, offlinedCPUs, err := profilecreator.CalculateCPUSets(systemInfo, sizing.ReservedCPUCount, args.OfflinedCPUCount, args.SplitReservedCPUsAcrossNUMA, args.DisableHT, args.PowerConsumptionMode == ultraLowLatency)
308329
if err != nil {
309330
return nil, fmt.Errorf("failed to compute the reserved and isolated CPUs: %v", err)
310331
}
@@ -407,13 +428,14 @@ type ProfileCreatorArgs struct {
407428
TMPolicy string `json:"topology-manager-policy"`
408429
PerPodPowerManagement *bool `json:"per-pod-power-management,omitempty"`
409430
EnableHardwareTuning bool `json:"enable-hardware-tuning,omitempty"`
431+
Autosize *bool `json:"autosize,omitempty"`
410432
// internal only this argument not passed by the user
411433
// but detected automatically
412434
createForHypershift bool
413435
}
414436

415437
func (pca *ProfileCreatorArgs) AddFlags(flags *pflag.FlagSet) {
416-
flags.IntVar(&pca.ReservedCPUCount, "reserved-cpu-count", 0, "Number of reserved CPUs (required)")
438+
flags.IntVar(&pca.ReservedCPUCount, "reserved-cpu-count", 0, "Number of reserved CPUs")
417439
flags.IntVar(&pca.OfflinedCPUCount, "offlined-cpu-count", 0, "Number of offlined CPUs")
418440
flags.BoolVar(&pca.SplitReservedCPUsAcrossNUMA, "split-reserved-cpus-across-numa", false, "Split the Reserved CPUs across NUMA nodes")
419441
flags.StringVar(&pca.MCPName, "mcp-name", "", "MCP name corresponding to the target machines (required)")
@@ -427,6 +449,7 @@ func (pca *ProfileCreatorArgs) AddFlags(flags *pflag.FlagSet) {
427449
flags.BoolVar(pca.PerPodPowerManagement, "per-pod-power-management", false, "Enable Per Pod Power Management")
428450
flags.BoolVar(&pca.EnableHardwareTuning, "enable-hardware-tuning", false, "Enable setting maximum cpu frequencies")
429451
flags.StringVar(&pca.NodePoolName, "node-pool-name", "", "Node pool name corresponding to the target machines (HyperShift only)")
452+
flags.BoolVar(pca.Autosize, "autosize", false, "autosize the control plane")
430453
}
431454

432455
func makePerformanceProfileFrom(profileData ProfileData) (runtime.Object, error) {
@@ -578,3 +601,7 @@ func setSelectorsFor(profileData *ProfileData, args *ProfileCreatorArgs) error {
578601
profileData.mcpSelector = mcpSelector
579602
return nil
580603
}
604+
605+
func isAutosizeEnabled(pcArgs *ProfileCreatorArgs) bool {
606+
return pcArgs.Autosize != nil && *pcArgs.Autosize
607+
}

0 commit comments

Comments
 (0)