/kind bug
Description
The allocation-rate shard policy throws away its own default CPU range during Initialize, so a scheduler configured with that policy and no arguments block ends up filtering on the range [0.0, 0.0]. Utilization is rounded to two decimals before the comparison, so only nodes that round to 0.00, meaning under half a percent, survive the filter. The shard is left with almost no nodes even though every node in the cluster is healthy and unassigned.
arguments is optional. It is omitempty in the config type, the policy README marks only name as required, and the README's own example omits arguments on the warmup entry. allocation-rate is also DefaultPolicyName, so it is the policy most people will reach for first.
The sibling policies do this differently, which is what makes it look like a slip rather than intent. warmup passes a pointer to the struct field that already holds its default from New(), so an absent key leaves that default in place, and node-limit is written the same way. allocation-rate reads into fresh local variables that start at zero, then assigns them over the struct unconditionally, so the 1.0 set in New() can never survive.
I found this by reading the code. I have not hit it on a live cluster. I did reproduce it against the controller code by feeding a ConfigMap through ParseShardingConfig and running ShardingManager.CalculateShardAssignments, which is the same path the controller uses. Output is below.
Steps to reproduce the issue
-
Configure the sharding ConfigMap with a policy chain that leaves out the optional arguments block:
schedulerConfigs:
- name: volcano
type: volcano
policies:
- name: allocation-rate
weight: 1
-
Have a few nodes in the cluster with normal, non-zero CPU utilization.
-
Look at what the volcano shard gets assigned, either in the NodeShard object or in the controller log line that reports the assignment.
Observed: the shard only picks up nodes at 0.00 utilization. In the run below it took 1 node out of 4.
Expected: with no range configured the policy should use the default range from New(), which is [0.0, 1.0], and all 4 nodes should be eligible.
Evidence and production path
Permalinks are pinned to 26f0052d4.
New() sets the intended defaults, maxCPURounded: 1.0 and cpuRangeRounded: 1.0:
|
// New creates a new allocation-rate policy instance. |
|
func New() policy.ShardPolicy { |
|
return &allocationRatePolicy{ |
|
maxCPURounded: 1.0, |
|
cpuRangeRounded: 1.0, |
|
} |
|
} |
Initialize then reads into local zero values and overwrites those fields, so the defaults never apply:
|
func (p *allocationRatePolicy) Initialize(args policy.Arguments) error { |
|
var minCPU, maxCPU float64 |
|
args.GetFloat64(&minCPU, ArgMinCPUUtil) |
|
args.GetFloat64(&maxCPU, ArgMaxCPUUtil) |
|
|
|
if minCPU < 0 || maxCPU > 1 || minCPU > maxCPU { |
|
return fmt.Errorf("invalid CPU utilization range [%.2f, %.2f]: must be within [0.0, 1.0] and min <= max", |
|
minCPU, maxCPU) |
|
} |
|
|
|
p.minCPURounded = roundUtil(minCPU) |
|
p.maxCPURounded = roundUtil(maxCPU) |
|
p.cpuRangeRounded = p.maxCPURounded - p.minCPURounded |
|
|
|
klog.V(3).Infof("Initialized allocation-rate policy: CPU range [%.2f, %.2f]", |
|
p.minCPURounded, p.maxCPURounded) |
|
return nil |
|
} |
GetFloat64 only writes through the pointer when the key is present, which is what lets a caller pre-seed a default:
|
func (a Arguments) GetFloat64(ptr *float64, key string) { |
|
if ptr == nil { |
|
return |
|
} |
|
|
|
argv, ok := a[key] |
|
if !ok { |
|
return |
|
} |
|
|
|
value, ok := argv.(float64) |
|
if !ok { |
|
// Try to convert from int |
|
if intVal, ok := argv.(int); ok { |
|
*ptr = float64(intVal) |
|
return |
|
} |
|
klog.Warningf("Could not parse argument: %v for key %s to float64", argv, key) |
|
return |
|
} |
|
|
|
*ptr = value |
|
} |
The two siblings use it the intended way, passing their own already-defaulted fields:
|
func (p *warmupPolicy) Initialize(args policy.Arguments) error { |
|
args.GetString(&p.warmupLabel, "warmupLabel") |
|
args.GetString(&p.warmupLabelValue, "warmupLabelValue") |
|
|
|
if p.warmupLabel == "" { |
|
return fmt.Errorf("warmupLabel cannot be empty") |
|
} |
|
|
|
klog.V(3).Infof("Initialized warmup policy: label %s=%s", |
|
p.warmupLabel, p.warmupLabelValue) |
|
return nil |
|
} |
|
func (p *nodeLimitPolicy) Initialize(args policy.Arguments) error { |
|
args.GetInt(&p.minNodes, "minNodes") |
|
args.GetInt(&p.maxNodes, "maxNodes") |
|
|
|
if p.minNodes < 0 { |
|
return fmt.Errorf("invalid minNodes %d: must be >= 0", p.minNodes) |
|
} |
|
if p.maxNodes > 0 && p.maxNodes < p.minNodes { |
|
return fmt.Errorf("invalid bounds [%d, %d]: maxNodes must be >= minNodes", |
|
p.minNodes, p.maxNodes) |
|
} |
|
|
|
klog.V(3).Infof("Initialized node-limit policy: bounds [%d, %d]", |
|
p.minNodes, p.maxNodes) |
|
return nil |
|
} |
Nothing upstream stops the config. validatePolicies only requires a non-empty name and a non-negative weight, and it never looks at whether arguments is present:
|
func validatePolicies(specIdx int, schedName string, policies []PolicySpec) error { |
|
for j, p := range policies { |
|
if p.Name == "" { |
|
return fmt.Errorf("schedulerConfigs[%d] (%s): policies[%d].name must not be empty", specIdx, schedName, j) |
|
} |
|
if p.Weight < 0 { |
|
return fmt.Errorf("schedulerConfigs[%d] (%s): policies[%d] (%s): weight must be >= 0 (omit for default of 1)", specIdx, schedName, j, p.Name) |
|
} |
|
if p.Name == "node-limit" { |
|
continue |
|
} |
|
if _, ok := p.Arguments["minNodes"]; ok { |
|
return fmt.Errorf("schedulerConfigs[%d] (%s): policies[%d] (%s): minNodes is only supported by the node-limit policy", specIdx, schedName, j, p.Name) |
|
} |
|
if _, ok := p.Arguments["maxNodes"]; ok { |
|
return fmt.Errorf("schedulerConfigs[%d] (%s): policies[%d] (%s): maxNodes is only supported by the node-limit policy", specIdx, schedName, j, p.Name) |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
// ShardingControllerOptions holds all runtime-configurable options for the |
|
// ShardingController. |
applyPolicyDefaults only synthesizes a chain when Policies is empty, so an explicit chain is passed through untouched:
|
func applyPolicyDefaults(spec *SchedulerConfigSpec) { |
|
if len(spec.Policies) == 0 { |
|
if len(spec.Arguments) == 0 { |
|
spec.Arguments = map[string]interface{}{ |
|
allocationrate.ArgMinCPUUtil: spec.CPUUtilizationMin, |
|
allocationrate.ArgMaxCPUUtil: spec.CPUUtilizationMax, |
|
} |
|
} |
|
spec.Policies = []PolicySpec{{ |
|
Name: DefaultPolicyName, |
|
Weight: 1, |
|
Arguments: spec.Arguments, |
|
}} |
|
if spec.PreferWarmupNodes { |
|
klog.Warningf(preferWarmupNodesDeprecatedMsg, spec.Name) |
|
spec.Policies = append(spec.Policies, PolicySpec{ |
|
Name: "warmup", |
|
Weight: 1, |
|
}) |
|
} |
|
} |
|
|
|
if (spec.MinNodes > 0 || spec.MaxNodes > 0) && !hasPolicy(spec.Policies, "node-limit") { |
|
klog.Warningf(nodeLimitScalarsDeprecatedMsg, spec.Name) |
|
spec.Policies = append(spec.Policies, PolicySpec{ |
|
Name: "node-limit", |
|
Arguments: map[string]interface{}{ |
|
"minNodes": spec.MinNodes, |
|
"maxNodes": spec.MaxNodes, |
|
}, |
|
}) |
|
} |
|
} |
and initializePolicies calls builder() then Initialize(ref.Arguments) with whatever the user wrote, including nothing:
|
func (sm *ShardingManager) initializePolicies() error { |
|
for _, config := range sm.schedulerConfigs { |
|
resolved := make([]resolvedPolicy, 0, len(config.Policies)) |
|
for _, ref := range config.Policies { |
|
builder, err := policy.GetPolicy(ref.Name) |
|
if err != nil { |
|
return fmt.Errorf("policy %s not found for scheduler %s: %v", |
|
ref.Name, config.Name, err) |
|
} |
|
instance := builder() |
|
if err := instance.Initialize(policy.Arguments(ref.Arguments)); err != nil { |
|
return fmt.Errorf("failed to initialize policy %s for scheduler %s: %v", |
|
ref.Name, config.Name, err) |
|
} |
|
rp := resolvedPolicy{Ref: ref, Instance: instance} |
|
if f, ok := instance.(policy.Filterer); ok { |
|
rp.Filter = f |
|
} |
|
if s, ok := instance.(policy.Scorer); ok { |
|
rp.Score = s |
|
} |
|
if sel, ok := instance.(policy.Selector); ok { |
|
rp.Select = sel |
|
} |
|
resolved = append(resolved, rp) |
|
klog.V(3).Infof("Initialized policy %s (weight=%d) for scheduler %s with args: %v", |
|
instance.Name(), ref.Weight, config.Name, ref.Arguments) |
|
} |
|
sm.policyCache[config.Name] = resolved |
|
} |
|
return nil |
|
} |
Driving that path with the ConfigMap above, four nodes at 0.00, 0.20, 0.50 and 0.90 utilization, and the real ShardingManager, the controller's own log line reports:
sharding_manager.go:164] Scheduler volcano assigned 1 nodes via [allocation-rate(w=1)]: [node-idle]
Adding the range explicitly, and changing nothing else:
arguments:
minCPUUtil: 0.0
maxCPUUtil: 1.0
sharding_manager.go:164] Scheduler volcano assigned 4 nodes via [allocation-rate(w=1)]: [node-hot node-busy node-light node-idle]
So the same nodes and the same policy differ only by whether the optional block was written out.
For completeness, the internal state either side of Initialize with an empty arguments map:
after New(): min=0.00 max=1.00 range=1.00
after Initialize(empty): min=0.00 max=0.00 range=0.00
and the sibling policy under the same conditions keeps its default, scoring a default-labelled node 1.0 with no arguments given.
The behavior arrived with the pluggable policy pipeline in 288d37cff and has not been touched since.
The fix looks like it is just reading into the struct fields the way the other two policies do, keeping the existing validation and rounding. Happy to send a PR with a test for the no-arguments case if that is the direction you want, or to do it differently if you would rather have the defaults expressed somewhere else.
What version of Volcano are you using?
v1.15.0, and master at 26f0052. v1.15.0 is the first release that contains it.
/kind bug
Description
The
allocation-rateshard policy throws away its own default CPU range duringInitialize, so a scheduler configured with that policy and noargumentsblock ends up filtering on the range[0.0, 0.0]. Utilization is rounded to two decimals before the comparison, so only nodes that round to 0.00, meaning under half a percent, survive the filter. The shard is left with almost no nodes even though every node in the cluster is healthy and unassigned.argumentsis optional. It isomitemptyin the config type, the policy README marks onlynameas required, and the README's own example omitsargumentson thewarmupentry.allocation-rateis alsoDefaultPolicyName, so it is the policy most people will reach for first.The sibling policies do this differently, which is what makes it look like a slip rather than intent.
warmuppasses a pointer to the struct field that already holds its default fromNew(), so an absent key leaves that default in place, andnode-limitis written the same way.allocation-ratereads into fresh local variables that start at zero, then assigns them over the struct unconditionally, so the1.0set inNew()can never survive.I found this by reading the code. I have not hit it on a live cluster. I did reproduce it against the controller code by feeding a ConfigMap through
ParseShardingConfigand runningShardingManager.CalculateShardAssignments, which is the same path the controller uses. Output is below.Steps to reproduce the issue
Configure the sharding ConfigMap with a policy chain that leaves out the optional
argumentsblock:Have a few nodes in the cluster with normal, non-zero CPU utilization.
Look at what the volcano shard gets assigned, either in the NodeShard object or in the controller log line that reports the assignment.
Observed: the shard only picks up nodes at 0.00 utilization. In the run below it took 1 node out of 4.
Expected: with no range configured the policy should use the default range from
New(), which is[0.0, 1.0], and all 4 nodes should be eligible.Evidence and production path
Permalinks are pinned to
26f0052d4.New()sets the intended defaults,maxCPURounded: 1.0andcpuRangeRounded: 1.0:volcano/pkg/controllers/sharding/policy/allocationrate/allocationrate.go
Lines 50 to 56 in 26f0052
Initializethen reads into local zero values and overwrites those fields, so the defaults never apply:volcano/pkg/controllers/sharding/policy/allocationrate/allocationrate.go
Lines 60 to 77 in 26f0052
GetFloat64only writes through the pointer when the key is present, which is what lets a caller pre-seed a default:volcano/pkg/controllers/sharding/policy/arguments.go
Lines 41 to 63 in 26f0052
The two siblings use it the intended way, passing their own already-defaulted fields:
volcano/pkg/controllers/sharding/policy/warmup/warmup.go
Lines 42 to 53 in 26f0052
volcano/pkg/controllers/sharding/policy/nodelimit/nodelimit.go
Lines 40 to 55 in 26f0052
Nothing upstream stops the config.
validatePoliciesonly requires a non-empty name and a non-negative weight, and it never looks at whetherargumentsis present:volcano/pkg/controllers/sharding/config.go
Lines 164 to 186 in 26f0052
applyPolicyDefaultsonly synthesizes a chain whenPoliciesis empty, so an explicit chain is passed through untouched:volcano/pkg/controllers/sharding/config.go
Lines 338 to 370 in 26f0052
and
initializePoliciescallsbuilder()thenInitialize(ref.Arguments)with whatever the user wrote, including nothing:volcano/pkg/controllers/sharding/sharding_manager.go
Lines 75 to 106 in 26f0052
Driving that path with the ConfigMap above, four nodes at 0.00, 0.20, 0.50 and 0.90 utilization, and the real
ShardingManager, the controller's own log line reports:Adding the range explicitly, and changing nothing else:
So the same nodes and the same policy differ only by whether the optional block was written out.
For completeness, the internal state either side of
Initializewith an empty arguments map:and the sibling policy under the same conditions keeps its default, scoring a default-labelled node 1.0 with no arguments given.
The behavior arrived with the pluggable policy pipeline in 288d37cff and has not been touched since.
The fix looks like it is just reading into the struct fields the way the other two policies do, keeping the existing validation and rounding. Happy to send a PR with a test for the no-arguments case if that is the direction you want, or to do it differently if you would rather have the defaults expressed somewhere else.
What version of Volcano are you using?
v1.15.0, and master at 26f0052. v1.15.0 is the first release that contains it.