Merge pull request #136652 from brejman/kep-5732-tas-placement-plugin-config

Extend NodeResourcesFit plugin to score placements in the PodGroup scheduling cycle
This commit is contained in:
Kubernetes Prow Robot
2026-03-16 23:37:40 +05:30
committed by GitHub
5 changed files with 388 additions and 35 deletions

View File

@@ -159,6 +159,11 @@ var ExpandedPluginsV1 = &config.Plugins{
{Name: names.DefaultBinder},
},
},
PlacementScore: config.PluginSet{
Enabled: []config.Plugin{
{Name: names.NodeResourcesFit, Weight: 1},
},
},
}
// PluginConfigsV1 default plugin configurations.

View File

@@ -47,6 +47,7 @@ var _ fwk.EnqueueExtensions = &Fit{}
var _ fwk.PreScorePlugin = &Fit{}
var _ fwk.ScorePlugin = &Fit{}
var _ fwk.SignPlugin = &Fit{}
var _ fwk.PlacementScorePlugin = &Fit{}
const (
// Name is the name of the plugin used in the plugin registry and configurations.
@@ -62,27 +63,27 @@ const (
// nodeResourceStrategyTypeMap maps strategy to scorer implementation
var nodeResourceStrategyTypeMap = map[config.ScoringStrategyType]scorer{
config.LeastAllocated: func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer {
resources := args.ScoringStrategy.Resources
config.LeastAllocated: func(strategy *config.ScoringStrategy) *resourceAllocationScorer {
resources := strategy.Resources
return &resourceAllocationScorer{
Name: string(config.LeastAllocated),
scorer: leastResourceScorer(resources),
resources: resources,
}
},
config.MostAllocated: func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer {
resources := args.ScoringStrategy.Resources
config.MostAllocated: func(strategy *config.ScoringStrategy) *resourceAllocationScorer {
resources := strategy.Resources
return &resourceAllocationScorer{
Name: string(config.MostAllocated),
scorer: mostResourceScorer(resources),
resources: resources,
}
},
config.RequestedToCapacityRatio: func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer {
resources := args.ScoringStrategy.Resources
config.RequestedToCapacityRatio: func(strategy *config.ScoringStrategy) *resourceAllocationScorer {
resources := strategy.Resources
return &resourceAllocationScorer{
Name: string(config.RequestedToCapacityRatio),
scorer: requestedToCapacityRatioScorer(resources, args.ScoringStrategy.RequestedToCapacityRatio.Shape),
scorer: requestedToCapacityRatioScorer(resources, strategy.RequestedToCapacityRatio.Shape),
resources: resources,
}
},
@@ -100,6 +101,7 @@ type Fit struct {
enableInPlacePodLevelResourcesVerticalScaling bool
handle fwk.Handle
*resourceAllocationScorer
placementScorer *resourceAllocationScorer
}
// ScoreExtensions of the Score plugin.
@@ -194,6 +196,17 @@ func (pl *Fit) SignPod(ctx context.Context, pod *v1.Pod) ([]fwk.SignFragment, *f
}, nil
}
func getScorer(strategy *config.ScoringStrategy) (*resourceAllocationScorer, error) {
if strategy == nil {
return nil, fmt.Errorf("scoring strategy not specified")
}
scorerFactory, exists := nodeResourceStrategyTypeMap[strategy.Type]
if !exists {
return nil, fmt.Errorf("scoring strategy %s is not supported", strategy.Type)
}
return scorerFactory(strategy), nil
}
// NewFit initializes a new plugin and returns it.
func NewFit(_ context.Context, plArgs runtime.Object, h fwk.Handle, fts feature.Features) (fwk.Plugin, error) {
args, ok := plArgs.(*config.NodeResourcesFitArgs)
@@ -204,17 +217,10 @@ func NewFit(_ context.Context, plArgs runtime.Object, h fwk.Handle, fts feature.
return nil, err
}
if args.ScoringStrategy == nil {
return nil, fmt.Errorf("scoring strategy not specified")
scorer, err := getScorer(args.ScoringStrategy)
if err != nil {
return nil, err
}
strategy := args.ScoringStrategy.Type
scorePlugin, exists := nodeResourceStrategyTypeMap[strategy]
if !exists {
return nil, fmt.Errorf("scoring strategy %s is not supported", strategy)
}
scorer := scorePlugin(args)
if fts.EnableDRAExtendedResource {
scorer.enableDRAExtendedResource = true
scorer.draManager = h.SharedDRAManager()
@@ -224,7 +230,7 @@ func NewFit(_ context.Context, plArgs runtime.Object, h fwk.Handle, fts feature.
scorer.DRACaches.celCache = cel.NewCache(10, cel.Features{EnableConsumableCapacity: fts.EnableDRAConsumableCapacity})
}
return &Fit{
pl := &Fit{
ignoredResources: sets.New(args.IgnoredResources...),
ignoredResourceGroups: sets.New(args.IgnoredResourceGroups...),
enableInPlacePodVerticalScaling: fts.EnableInPlacePodVerticalScaling,
@@ -235,7 +241,20 @@ func NewFit(_ context.Context, plArgs runtime.Object, h fwk.Handle, fts feature.
enableDRAExtendedResource: fts.EnableDRAExtendedResource,
enableInPlacePodLevelResourcesVerticalScaling: fts.EnableInPlacePodLevelResourcesVerticalScaling,
resourceAllocationScorer: scorer,
}, nil
}
if fts.EnableTopologyAwareWorkloadScheduling {
placementScorer, err := getScorer(&config.ScoringStrategy{
Type: config.MostAllocated,
Resources: args.ScoringStrategy.Resources,
})
if err != nil {
return nil, err
}
pl.placementScorer = placementScorer
}
return pl, nil
}
// ResourceRequestsOptions contains feature gate flags for resource request computation.
@@ -760,3 +779,13 @@ func (f *Fit) Score(ctx context.Context, state fwk.CycleState, pod *v1.Pod, node
return f.score(ctx, pod, nodeInfo, s.podRequests, s.draPreScoreState)
}
// PlacementScoreExtensions is not used by this plugin.
func (f *Fit) PlacementScoreExtensions() fwk.PlacementScoreExtensions {
return nil
}
// ScorePlacement scores capacity ratio on all nodes in the placement including all assigned pod group pods' resource requests.
func (f *Fit) ScorePlacement(ctx context.Context, state fwk.PodGroupCycleState, podGroup fwk.PodGroupInfo, placement *fwk.PodGroupAssignments) (int64, *fwk.Status) {
return f.placementScorer.scorePlacement(ctx, podGroup, placement)
}

View File

@@ -29,6 +29,7 @@ import (
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apiruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
utilfeature "k8s.io/apiserver/pkg/util/feature"
featuregatetesting "k8s.io/component-base/featuregate/testing"
fwk "k8s.io/kube-scheduler/framework"
@@ -2292,3 +2293,266 @@ func testFitSignPod(tCtx ktesting.TContext) {
})
}
}
type podAssignment struct {
pod *v1.Pod
nodeName string
}
func (pa *podAssignment) GetPod() *v1.Pod {
return pa.pod
}
func (pa *podAssignment) GetNodeName() string {
return pa.nodeName
}
func TestScorePlacement_Resources(t *testing.T) {
featuregatetesting.SetFeatureGatesDuringTest(t, utilfeature.DefaultFeatureGate, featuregatetesting.FeatureOverrides{
features.GenericWorkload: true,
features.TopologyAwareWorkloadScheduling: true,
})
testCases := []struct {
name string
nodes []*v1.Node
placementNodeNames []string
podGroupPods []*v1.Pod
podGroupAssignments map[types.UID]string
preExistingPods []*v1.Pod
resourceSpec []config.ResourceSpec
expectedScore int64
}{
{
name: "Computes score based on total requests",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemory("1000m", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemory("2000m", "2000")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node2",
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 1},
{Name: "memory", Weight: 1},
},
// CPU: (1000m + 2000m) / (5000m + 5000m) = 0.3
// Memory: (0 + 2000) / (5000 + 5000) = 0.2
// Score: MaxScore * (0.3 + 0.2) / 2 = 25
expectedScore: 25,
},
{
name: "Computes score using weights",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemory("1000m", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemory("2000m", "2000")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node2",
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 4},
{Name: "memory", Weight: 1},
},
// CPU: (1000m + 2000m) / (5000m + 5000m) = 0.3
// Memory: (0 + 2000) / (5000 + 5000) = 0.2
// Score: MaxScore * (4 * 0.3 + 0.2) / 5 = 28
expectedScore: 28,
},
{
name: "Handles multiple pods per node",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemory("1000m", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemory("2000m", "2000")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node1",
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 4},
{Name: "memory", Weight: 1},
},
// CPU: (1000m + 2000m) / (5000m + 5000m) = 0.3
// Memory: (0 + 2000) / (5000 + 5000) = 0.2
// Score: MaxScore * (4 * 0.3 + 0.2) / 5 = 28
expectedScore: 28,
},
{
name: "Does not include nodes from outside of placement",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
st.MakeNode().Name("node3").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemory("1000m", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemory("2000m", "2000")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node2",
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 1},
{Name: "memory", Weight: 1},
},
expectedScore: 25,
},
{
name: "Includes pre-existing pods from outside of pod group",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemory("5000m", "5000")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemory("1000m", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemory("2000m", "2000")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node2",
},
preExistingPods: []*v1.Pod{
st.MakePod().Node("node1").UID("baz").Req(cpuAndMemory("1000m", "0")).Obj(),
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 1},
{Name: "memory", Weight: 1},
},
// CPU: (1000m + 2000m + 1000m) / (5000m + 5000m) = 0.4
// Memory: (0 + 2000 + 0) / (5000 + 5000) = 0.2
// Score: MaxScore * (0.3 + 0.2) / 2 = 30
expectedScore: 30,
},
{
name: "Includes scalar resources if any pod in pod group has nonzero requests",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemoryAndGpu("5000m", "5000", "5")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemoryAndGpu("5000m", "5000", "5")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemoryAndGpu("1000m", "0", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemoryAndGpu("1000m", "2000", "1")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node2",
},
preExistingPods: []*v1.Pod{
st.MakePod().Node("node1").UID("baz").Req(cpuAndMemoryAndGpu("1000m", "0", "0")).Obj(),
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 1},
{Name: "memory", Weight: 1},
{Name: "nvidia.com/gpu", Weight: 1},
},
// CPU: (1000m + 1000m + 1000m) / (5000m + 5000m) = 0.3
// Memory: (0 + 2000 + 0) / (5000 + 5000) = 0.2
// GPU: (0 + 1 + 0) / (5 + 5) = 0.1
// Score: MaxScore * (0.3 + 0.2 + 0.1) / 3 = 20
expectedScore: 20,
},
{
name: "Does not include scalar resources if all pods in pod group have zero requests",
nodes: []*v1.Node{
st.MakeNode().Name("node1").Capacity(cpuAndMemoryAndGpu("5000m", "5000", "5")).Obj(),
st.MakeNode().Name("node2").Capacity(cpuAndMemoryAndGpu("5000m", "5000", "5")).Obj(),
},
placementNodeNames: []string{"node1", "node2"},
podGroupPods: []*v1.Pod{
st.MakePod().UID("foo").Req(cpuAndMemoryAndGpu("1000m", "0", "0")).Obj(),
st.MakePod().UID("bar").Req(cpuAndMemoryAndGpu("1000m", "2000", "0")).Obj(),
},
podGroupAssignments: map[types.UID]string{
"foo": "node1",
"bar": "node2",
},
preExistingPods: []*v1.Pod{
st.MakePod().Node("node1").UID("baz").Req(cpuAndMemoryAndGpu("1000m", "0", "1")).Obj(),
},
resourceSpec: []config.ResourceSpec{
{Name: "cpu", Weight: 1},
{Name: "memory", Weight: 1},
{Name: "nvidia.com/gpu", Weight: 1},
},
// CPU: (1000m + 1000m + 1000m) / (5000m + 5000m) = 0.3
// Memory: (0 + 2000 + 0) / (5000 + 5000) = 0.2
// GPU: not included as it isn't requested by the pods
// Score: MaxScore * (0.3 + 0.2) / 2 = 25
expectedScore: 25,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tCtx := ktesting.Init(t)
snapshot := cache.NewSnapshot(tc.preExistingPods, tc.nodes)
fh, _ := runtime.NewFramework(tCtx, nil, nil, runtime.WithSnapshotSharedLister(snapshot))
scoringStrategy := defaultScoringStrategy.DeepCopy()
scoringStrategy.Resources = tc.resourceSpec
plugin, err := NewFit(tCtx, &config.NodeResourcesFitArgs{
ScoringStrategy: scoringStrategy,
}, fh, plfeature.Features{EnableTopologyAwareWorkloadScheduling: true})
if err != nil {
t.Fatal(err)
}
placementNodes := make([]fwk.NodeInfo, 0, len(tc.placementNodeNames))
for _, name := range tc.placementNodeNames {
nodeInfo, err := snapshot.NodeInfos().Get(name)
if err != nil {
t.Fatal(err)
}
placementNodes = append(placementNodes, nodeInfo)
}
proposedAssignments := make([]fwk.ProposedAssignment, 0, len(tc.podGroupPods))
for _, pod := range tc.podGroupPods {
if nodeName, ok := tc.podGroupAssignments[pod.UID]; ok {
proposedAssignments = append(proposedAssignments, &podAssignment{
pod: pod,
nodeName: nodeName,
})
}
}
podGroupInfo := &framework.PodGroupInfo{
UnscheduledPods: tc.podGroupPods,
}
podGroupAssignments := &fwk.PodGroupAssignments{
Placement: &fwk.Placement{
Nodes: placementNodes,
},
ProposedAssignments: proposedAssignments,
}
score, status := plugin.(*Fit).ScorePlacement(tCtx, framework.NewCycleState(), podGroupInfo, podGroupAssignments)
if !status.IsSuccess() {
t.Fatalf("ScorePlacement failed: %v", status.AsError())
}
if score != tc.expectedScore {
t.Fatalf("Unexpected score, want %v got %v", tc.expectedScore, score)
}
})
}
}

View File

@@ -36,7 +36,7 @@ import (
)
// scorer is decorator for resourceAllocationScorer
type scorer func(args *config.NodeResourcesFitArgs) *resourceAllocationScorer
type scorer func(strategy *config.ScoringStrategy) *resourceAllocationScorer
// DRACaches holds various caches used for DRA-related computations
type DRACaches struct {
@@ -150,9 +150,29 @@ func (r *resourceAllocationScorer) score(
return 0, fwk.NewStatus(fwk.Error, "resources not found")
}
allocated := make([]int64, len(r.resources))
requested := make([]int64, len(r.resources))
allocatable := make([]int64, len(r.resources))
requested, allocated, allocatable := r.calculateNodeAllocatableRequest(ctx, nodeInfo, podRequests, draPreScoreState)
score := r.scorer(requested, allocated, allocatable)
if loggerV := logger.V(10); loggerV.Enabled() { // Serializing these maps is costly.
loggerV.Info("Listed internal info for allocatable resources, requested resources and score", "pod",
klog.KObj(pod), "node", klog.KObj(node), "resourceAllocationScorer", r.Name,
"allocatableResource", allocatable, "requestedResource", requested, "resourceScore", score,
)
}
return score, nil
}
func (r *resourceAllocationScorer) calculateNodeAllocatableRequest(
ctx context.Context,
nodeInfo fwk.NodeInfo,
podRequests []int64,
draPreScoreState *draPreScoreState,
) (requested []int64, allocated []int64, allocatable []int64) {
requested = make([]int64, len(r.resources))
allocated = make([]int64, len(r.resources))
allocatable = make([]int64, len(r.resources))
for i := range r.resources {
resource := v1.ResourceName(r.resources[i].Name)
// If it's an extended resource, and the pod doesn't request it.
@@ -169,17 +189,7 @@ func (r *resourceAllocationScorer) score(
allocated[i] = nodeAllocated
requested[i] = allocated[i] + podRequests[i]
}
score := r.scorer(requested, allocated, allocatable)
if loggerV := logger.V(10); loggerV.Enabled() { // Serializing these maps is costly.
loggerV.Info("Listed internal info for allocatable resources, requested resources and score", "pod",
klog.KObj(pod), "node", klog.KObj(node), "resourceAllocationScorer", r.Name,
"allocatableResource", allocatable, "requestedResource", requested, "resourceScore", score,
)
}
return score, nil
return requested, allocated, allocatable
}
// calculateResourceAllocatableRequest returns 2 parameters:
@@ -190,7 +200,7 @@ func (r *resourceAllocationScorer) calculateResourceAllocatableRequest(
nodeInfo fwk.NodeInfo,
resource v1.ResourceName,
draPreScoreState *draPreScoreState,
) (int64, int64) {
) (allocatable int64, allocated int64) {
requested := nodeInfo.GetNonZeroRequested()
if r.useRequested {
requested = nodeInfo.GetRequested()
@@ -491,3 +501,44 @@ func (r *resourceAllocationScorer) deviceMatchesClass(ctx context.Context, devic
return true, nil
}
func (r *resourceAllocationScorer) scorePlacement(
ctx context.Context,
podGroupInfo fwk.PodGroupInfo,
podGroupAssignments *fwk.PodGroupAssignments,
) (int64, *fwk.Status) {
requested := make([]int64, len(r.resources))
// Calculate requests for the pod group pods scheduled for this placement
for _, assignment := range podGroupAssignments.ProposedAssignments {
pod := assignment.GetPod()
podRequests := r.calculatePodResourceRequestList(pod, r.resources)
for i := range len(r.resources) {
requested[i] += podRequests[i]
}
}
allocatable := make([]int64, len(r.resources))
allocated := make([]int64, len(r.resources))
// Calculate resources on all nodes in this placement
for _, node := range podGroupAssignments.Nodes {
_, nodeAllocated, nodeAllocatable := r.calculateNodeAllocatableRequest(ctx, node, requested, nil)
for i := range r.resources {
allocatable[i] += nodeAllocatable[i]
allocated[i] += nodeAllocated[i]
// requested includes both the already existing pods
// and the pod group pods that are being scheduled for this placement
requested[i] += nodeAllocated[i]
}
}
score := r.scorer(requested, allocated, allocatable)
logger := klog.FromContext(ctx)
if loggerV := logger.V(10); loggerV.Enabled() { // Serializing these maps is costly.
loggerV.Info("Listed internal info for placement's allocatable resources, requested resources and placement score", "podGroup",
klog.KRef(podGroupInfo.GetNamespace(), podGroupInfo.GetName()), "placement", podGroupAssignments.Name, "resourceAllocationScorer", r.Name,
"allocatableResource", allocatable, "requestedResource", requested, "resourceScore", score,
)
}
return score, nil
}

View File

@@ -680,6 +680,10 @@ func (pgi *PodGroupInfo) GetNamespace() string {
return pgi.Namespace
}
func (pgi *PodGroupInfo) GetUnscheduledPods() []*v1.Pod {
return pgi.UnscheduledPods
}
// PodInfo is a wrapper to a Pod with additional pre-computed information to
// accelerate processing. This information is typically immutable (e.g., pre-processed
// inter-pod affinity selectors).