Skip to content

Commit 378e346

Browse files
authored
feat(scheduler): Implement node scoring in NUMA plugin (#1965)
Signed-off-by: itsomri <omric@nvidia.com>
1 parent a4bee80 commit 378e346

9 files changed

Lines changed: 397 additions & 55 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Added
2+
body: |-
3+
NUMA-aware node scoring preferring fewest-NUMA-zone placement

docs/developer/designs/numa-topology/README.md

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -601,38 +601,59 @@ regardless; Appendix A is the in-plugin fallback if the assumption proves insuff
601601

602602
## v2: Optimization & scoring
603603

604-
v1 decides *feasibility* — can this node host the pod without a `TopologyAffinityError`. v2
605-
decides *which feasible node is best*, via a node score (`AddNodeOrderFn`, a new band in
606-
`scores/scores.go`). It reuses v1's evaluators and per-zone model unchanged: it only **ranks**
607-
nodes, never alters the admit decision.
604+
v2 adds scoring based on NUMA placement: nodes that can fit a NUMA-sensitive task in fewer zones
605+
are ranked higher. Nodes that can't admit the task are ranked lower. This enables us to support
606+
`best-effort` mode better.
607+
608+
The upstream scheduler numa plugin supports different scoring strategies - `LeastNUMANodes`,
609+
`BalancedAllocation`, `LeastAllocated` and `MostAllocated` - the last three are only relevant to
610+
`single-numa-node` mode. `LeastNUMANodes` seems the most relevant for our use cases, but we can
611+
support more modes, and welcome community feedback on this.
608612

609613
### What scoring adds
610614

611615
- **Optimize `best-effort` performance.** On a `best-effort` node the kubelet never rejects — it
612616
silently runs the pod *unaligned* when it can't fit a NUMA node, costing throughput. v1 does
613617
nothing for `best-effort` (there is no admission error to prevent). v2 **scores** `best-effort`
614-
nodes by whether the pod's resources *can* be aligned there, steering it toward a node where
615-
the kubelet's best-effort alignment will actually succeed — turning a silent performance loss
616-
into a good placement. This is the primary motivation for v2.
617-
- **Prefer tighter, less-fragmented fit** on feasible `single-numa-node` / `restricted` nodes, so
618-
later pods still find aligned room, and multi-NUMA pods span the fewest zones.
619-
620-
### Scoring strategies
621-
622-
Reusing the upstream NodeResourceTopology scoring vocabulary, computed over the plugin's per-zone
623-
model:
624-
625-
- **LeastNUMANodes** (policy-agnostic) — prefer nodes where the pod spans the fewest NUMA nodes
626-
(ideally one). This is the core `best-effort` steering and the multi-NUMA-span minimizer.
627-
- **LeastAllocated / MostAllocated / BalancedAllocation** — spread vs. bin-pack vs. balance
628-
per-zone utilization, for fragmentation control on the aligned policies; selectable via config.
618+
nodes by how few zones the pod's resources *can* be aligned to there, steering it toward a node
619+
where the kubelet's best-effort alignment will actually succeed. This is the primary motivation for v2.
620+
- **Prefer tighter, fewer-zone fit** on feasible `restricted` nodes, where the kubelet forces the
621+
pod to span its preferred width `w` (which differs per node by per-zone `Allocatable`): rank
622+
nodes by `w` so a pod that aligns to one zone on node A beats spanning two on node B.
623+
- **Sink infeasible nodes so the predicate short-circuits.** `OrderedNodesByTask` scores *every*
624+
candidate node — including ones the predicate will reject — and the action then runs `FittingNode`
625+
(the predicate) lazily **in score order**, stopping at the first fit. So ranking a node the
626+
kubelet can't NUMA-align *below* one it can makes the predicate hit a feasible node first and skip
627+
evaluating the infeasible ones. This applies to **`single-numa-node`** too: its feasible span is
628+
always 1, but feasibility itself is a ranking signal, so scoring is *not* a no-op there — it
629+
front-loads the alignable nodes. (Correctness still rests on the predicate; the score only reorders.)
630+
631+
### The fewest-zones span, per policy
632+
633+
Scoring routes every candidate node through one reusable function, `alignmentSpan(task, node) →
634+
(zones int, aligned bool)`, and the score is a function of **both** outputs: `aligned=false` sinks
635+
the node (worst score), and among aligned nodes fewer `zones` scores higher.
636+
637+
| Policy | `alignmentSpan` when aligned | `aligned=false` when | Predicate outcome |
638+
| --- | --- | --- | --- |
639+
| `single-numa-node` | `1` | no single zone fits by `Available` | rejects (filtered) |
640+
| `restricted` | the forced preferred width `w` | preferred widths disagree, or no width-`w` mask fits | rejects (filtered) |
641+
| `best-effort` | greedy narrowest zone mask that fits by `Available` (width = span) | even all N zones can't cover the request (pod runs unaligned) | passes (best-effort never rejects) |
642+
643+
For the two rejecting policies, `aligned` is the *same bit the predicate computes*, so a sunk node
644+
is one the predicate would filter — the score just reorders the funnel. For `best-effort`,
645+
`aligned=false` is the unaligned case: still selectable (worst-but-finite score), because
646+
`best-effort` offers no other node any guarantee.
629647

630648
### Notes
631649

632650
- Scoring runs on the same predicted per-zone state as v1, so the prediction caveats carry over —
633651
but a score is only a *preference*, so a misprediction costs ranking quality, never correctness.
634-
- `best-effort` scoring is the one place the plugin touches `best-effort` nodes at all; v1 leaves
635-
them untouched, and the admit decision for `single-numa-node` / `restricted` is unchanged.
652+
- The span metric is pure zone-count and policy-agnostic, so span-1 scores identically across
653+
`single-numa-node`, `restricted`, and `best-effort` — a mixed-policy candidate set ranks coherently.
654+
- **No NUMA-distance awareness in v2.** The score is zone *count* only; inter-zone distance
655+
(`Zone.Costs`) is not ingested and no scoring seam is reserved for it. A distance metric (e.g. a
656+
v3 "minimax") would be a separate axis added later if pursued.
636657

637658
## v3: Pod-level NUMA policy (scheduler-enforced)
638659

pkg/scheduler/plugins/numa/evaluator.go

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package numa
55

66
import (
7+
"math"
78
"sort"
89

910
v1 "k8s.io/api/core/v1"
@@ -14,8 +15,11 @@ import (
1415
"github.com/kai-scheduler/KAI-scheduler/pkg/scheduler/api/resource_info"
1516
)
1617

17-
// stackZones bounds the mask/scratch stack buffers; nodes with more NUMA zones fall back to heap.
18-
const stackZones = 16
18+
// stackZones and stackAware bound the mask/scratch stack buffers; larger nodes fall back to heap.
19+
const (
20+
stackZones = 16
21+
stackAware = 16
22+
)
1923

2024
// zoneAllocation accumulates, per zone index, the amounts to place there (as a ResourceVector delta).
2125
// placementFromAllocation materializes it into a pod_info.NUMAPlacement.
@@ -49,29 +53,31 @@ func (pp *numaPlugin) alignedAware(task *pod_info.PodInfo, node *node_info.NodeI
4953
return out
5054
}
5155

52-
// allocatable reports whether the kubelet Topology Manager would align the task on the node. A task
53-
// the plugin does not constrain passes through as true.
56+
// allocatable reports whether the kubelet Topology Manager would align the task on the node (the
57+
// predicate). A task the plugin does not filter passes through as true.
5458
func (pp *numaPlugin) allocatable(task *pod_info.PodInfo, node *node_info.NodeInfo) bool {
59+
if node == nil || !pp.shouldFilter(task, node.NumaTopology) {
60+
return true
61+
}
5562
return pp.solveTask(task, node, nil)
5663
}
5764

5865
// evaluate returns the task's expected per-zone allocation on the node (nil for a task the plugin
59-
// does not constrain). Used by the placement path; the predicate uses allocatable (feasibility only).
66+
// does not account for). Used by the placement and scoring paths.
6067
func (pp *numaPlugin) evaluate(task *pod_info.PodInfo, node *node_info.NodeInfo) (zoneAllocation, bool) {
68+
if node == nil || !pp.shouldScore(task, node.NumaTopology) {
69+
return nil, true
70+
}
6171
alloc := zoneAllocation{}
6272
if !pp.solveTask(task, node, alloc) {
6373
return nil, false
6474
}
6575
return alloc, true
6676
}
6777

68-
// solveTask resolves the task's requests and scope for the node and runs solve. A task the plugin
69-
// does not constrain passes through as admitted. When alloc is non-nil, solve records the placement
70-
// into it; when nil, it only decides feasibility (zero-allocation).
78+
// solveTask runs the evaluator on a node the caller has already gated. When alloc is non-nil, solve
79+
// records the placement into it; when nil, it only decides feasibility (zero-allocation).
7180
func (pp *numaPlugin) solveTask(task *pod_info.PodInfo, node *node_info.NodeInfo, alloc zoneAllocation) bool {
72-
if node == nil || !pp.shouldHandle(task, node.NumaTopology) {
73-
return true
74-
}
7581
topo := node.NumaTopology
7682
aware := pp.alignedAware(task, node)
7783
concurrent, serial := pp.numaRequestsFor(task, topo.VectorMap).forScope(topo.Scope)
@@ -118,10 +124,14 @@ func solve(topo *node_info.NumaTopology, aware []int, concurrent, serial []resou
118124
// feasibleMask picks the mask the policy's evaluator would choose for one request, under the current
119125
// availability view (Available minus consumed).
120126
func feasibleMask(topo *node_info.NumaTopology, aware []int, req resource_info.ResourceVector, consumed []float64, width int, maskBuf []int) ([]int, bool) {
121-
if topo.Policy == node_info.TopologyPolicySingleNUMANode {
127+
switch topo.Policy {
128+
case node_info.TopologyPolicySingleNUMANode:
122129
return singleNUMAEvaluator{}.fit(topo, aware, req, consumed, width, maskBuf)
130+
case node_info.TopologyPolicyBestEffort:
131+
return bestEffortEvaluator{}.fit(topo, aware, req, consumed, width, maskBuf)
132+
default:
133+
return restrictedEvaluator{}.fit(topo, aware, req, consumed, width, maskBuf)
123134
}
124-
return restrictedEvaluator{}.fit(topo, aware, req, consumed, width, maskBuf)
125135
}
126136

127137
// singleNUMAEvaluator (single-numa-node) requires each request to fit entirely within one NUMA zone,
@@ -137,6 +147,79 @@ func (singleNUMAEvaluator) fit(topo *node_info.NumaTopology, aware []int, req re
137147
return nil, false
138148
}
139149

150+
// bestEffortEvaluator implements the best-effort policy, which never rejects, so its result feeds
151+
// scoring and the in-cycle ledger, never an admit decision.
152+
type bestEffortEvaluator struct{}
153+
154+
// fit greedily grows a zone mask until it covers the request, returning the mask and whether it fits:
155+
// 1. Start with no zones; the unmet demand is the full request.
156+
// 2. Each round, add the unused zone that covers the most unmet demand (the sum, over
157+
// still-needed resources, of min(remaining, available in that zone)), then subtract that zone's
158+
// availability from the demand.
159+
// 3. Stop when every resource is met (fits) or no remaining zone can help (does not fit).
160+
//
161+
// The number of zones picked is the span. Taking the most-covering zone first yields the fewest
162+
// zones exactly when one resource dominates.
163+
func (bestEffortEvaluator) fit(topo *node_info.NumaTopology, aware []int, req resource_info.ResourceVector, consumed []float64, width int, maskBuf []int) ([]int, bool) {
164+
var remArr [stackAware]float64
165+
remaining := remArr[:0]
166+
if len(aware) > stackAware {
167+
remaining = make([]float64, 0, len(aware))
168+
}
169+
for _, idx := range aware {
170+
remaining = append(remaining, req.Get(idx))
171+
}
172+
var usedArr [stackZones]bool
173+
used := usedArr[:]
174+
if len(topo.Zones) > stackZones {
175+
used = make([]bool, len(topo.Zones))
176+
}
177+
178+
mask := maskBuf[:0]
179+
for len(mask) < len(topo.Zones) {
180+
if allMet(remaining) {
181+
return mask, true
182+
}
183+
best, bestCover := -1, 0.0
184+
for z := range topo.Zones {
185+
if used[z] {
186+
continue
187+
}
188+
cover := 0.0
189+
for i, idx := range aware {
190+
if remaining[i] <= 0 {
191+
continue
192+
}
193+
cover += math.Min(remaining[i], availableAt(topo, consumed, width, z, idx))
194+
}
195+
if cover > bestCover {
196+
best, bestCover = z, cover
197+
}
198+
}
199+
if best < 0 {
200+
break
201+
}
202+
used[best] = true
203+
mask = append(mask, best)
204+
for i, idx := range aware {
205+
remaining[i] -= availableAt(topo, consumed, width, best, idx)
206+
}
207+
}
208+
if allMet(remaining) {
209+
return mask, true
210+
}
211+
return nil, false
212+
}
213+
214+
func allMet(remaining []float64) bool {
215+
for _, r := range remaining {
216+
if r > 0 {
217+
return false
218+
}
219+
}
220+
return true
221+
}
222+
140223
// restrictedEvaluator reproduces the kubelet hint merge: all per-resource preferred widths (from
141224
// static Allocatable) must agree, and a mask of that width must satisfy every resource against
142225
// Available. single-numa-node is the width==1 case.

0 commit comments

Comments
 (0)