Anding the NUMA Bitmasks: How the Topology Manager Merges Three Providers
The kubelet's topology manager merges hints from the CPU, memory and device managers by bitwise-AND of NUMA affinity bitmasks, enumerating the Cartesian product of all provider hints.
TL;DR: The topology manager gathers hints from every hint provider — the CPU manager, the memory manager, the device manager — and merges them by bitwise AND of the NUMA affinity bitmasks. The intersection is the only set of NUMA nodes that satisfies all providers at once. Because the merge space is the Cartesian product of every provider’s hint list, the code enumerates the permutations recursively. The subtle part is the score aggregation: a
Scoreof 0 does not mean “zero utilization”, it means “no signal”, so only hints with an affinity and a non-zero score are averaged.
Topology-aware allocation on a NUMA machine is a constraint satisfaction problem. The CPU manager wants the pod’s CPUs on one node. The memory manager wants its memory on one node. The device manager wants the GPU on a node that is also the one with the CPUs. Each of these is a partial answer: a set of acceptable NUMA nodes. The topology manager’s job is to intersect them.
The obvious implementation of “intersect these sets” is to materialise the sets and intersect them pairwise. Kubernetes does not do that, for a reason that becomes obvious once you look at the data structures: the affinity is already a bitmask, and the intersection of bitmasks is a single machine instruction.
The bitmask
The affinity is a uint64, one bit per NUMA node:
// pkg/kubelet/cm/topologymanager/bitmask/bitmask.go:45
type bitMask uint64and the setter enforces the range:
// pkg/kubelet/cm/topologymanager/bitmask/bitmask.go:64
func (s *bitMask) Add(bits ...int) error {
mask := *s
for _, i := range bits {
if i < 0 || i >= 64 {
return fmt.Errorf("bit number must be in range 0-63")
}
mask |= 1 << uint64(i)
}
*s = mask
return nil
}bit number must be in range 0-63. A machine with 65 NUMA nodes cannot be
represented. In practice the topology manager refuses to start long before
that, because of a different explosion.
The Cartesian product
Each provider returns a list of hints — one per resource it manages, each with its own affinity mask and its own preferred flag. Merging them means trying every combination, which the code does with a recursive generator:
// pkg/kubelet/cm/topologymanager/policy.go:377
func iterateAllProviderTopologyHints(allProviderHints [][]TopologyHint, callback func([]TopologyHint)) {
// Internal helper function to accumulate the permutation before calling the callback.
var iterate func(i int, accum []TopologyHint)
iterate = func(i int, accum []TopologyHint) {
// Base case: we have looped through all providers and have a full permutation.
if i == len(allProviderHints) {
callback(accum)
return
}
// Loop through all hints for provider 'i', and recurse to build the
// permutation of this hint with all hints from providers 'i++'.
for j := range allProviderHints[i] {
iterate(i+1, append(accum, allProviderHints[i][j]))
}
}
iterate(0, []TopologyHint{})
}The growth is multiplicative: with three providers offering 8 hints each, that is 512 permutations to consider per admission. This is exactly why the number of NUMA nodes is capped:
// pkg/kubelet/cm/topologymanager/topology_manager.go:32
const (
// defaultMaxAllowableNUMANodes specifies the maximum number of NUMA Nodes that
// the TopologyManager supports on the underlying machine.
//
// At present, having more than this number of NUMA Nodes will result in a
// state explosion when trying to enumerate possible NUMAAffinity masks and
// generate hints for them. As such, if more NUMA Nodes than this are
// present on a machine and the TopologyManager is enabled, an error will
// be returned and the TopologyManager will not be loaded.
defaultMaxAllowableNUMANodes = 8Eight NUMA nodes by default. Not because the bitmask cannot hold more, but because the enumeration cannot. The check is at startup:
// pkg/kubelet/cm/topologymanager/topology_manager.go:206
if topologyPolicyName != PolicyNone && len(numaInfo.Nodes) > opts.MaxAllowableNUMANodes {
return nil, fmt.Errorf("unsupported on machines with more than %v NUMA Nodes", opts.MaxAllowableNUMANodes)
}The max-allowable-numa-nodes policy option can raise the limit above 8 —
lowering it below 8 is rejected outright, raising it is allowed with a
warning (pkg/kubelet/cm/topologymanager/policy_options.go:89).
The merge
Each permutation is merged into a single hint by ANDing the masks:
// pkg/kubelet/cm/topologymanager/policy.go:71
func mergePermutation(logger klog.Logger, defaultAffinity bitmask.BitMask, permutation []TopologyHint) TopologyHint {
// Get the NUMANodeAffinity from each hint in the permutation and see if any
// of them encode unpreferred allocations.
preferred := true
var numaAffinities []bitmask.BitMask
for _, hint := range permutation {
// Only consider hints that have an actual NUMANodeAffinity set.
if hint.NUMANodeAffinity != nil {
numaAffinities = append(numaAffinities, hint.NUMANodeAffinity)
// Only mark preferred if all affinities are equal.
if !hint.NUMANodeAffinity.IsEqual(numaAffinities[0]) {
preferred = false
}
}
// Only mark preferred if all affinities are preferred.
if !hint.Preferred {
preferred = false
}
}
// Merge the affinities using a bitwise-and operation.
mergedAffinity := bitmask.And(defaultAffinity, numaAffinities...)Two subtleties. First, defaultAffinity — the mask of all NUMA nodes on
the machine — is ANDed in as well, so a hint can never escape the machine’s
real topology. Second, the “preferred” flag requires all the affinities
in the permutation to be equal, not just non-empty. If the CPU manager says
“node 0” and the memory manager says “node 1”, the AND is empty and the
merged hint is not preferred.
The merged affinity can legitimately be empty (zero bits): that means “no single NUMA node satisfies everyone”, which is a real outcome the policy decides what to do with.
The score: zero is not zero
This is the part that catches people out. The merged hint carries a Score
— a packing-efficiency signal — and the aggregation looks wrong at first
glance:
// pkg/kubelet/cm/topologymanager/policy.go:53
func aggregateHintScores(permutation []TopologyHint) (int64, bool) {
var sum int64
var count int64
for _, hint := range permutation {
if hint.NUMANodeAffinity != nil && hint.Score > 0 {
sum += hint.Score
count++
}
}
if count == 0 {
return 0, false
}
return sum / count, true
}A Score of 0 means “no signal”. It does not mean “the NUMA node is
completely empty” — which would be zero percent utilization, the best
possible packing. If you averaged zeros in, a provider with nothing to say
would drag every merged score toward zero, and a merge of {0, 0, 90} would
score 30 instead of 90. The count == 0 early return handles the case where
nobody has a signal: the merged score is 0 and hasScores is false.
The doc comment above the function states the rule outright: “Only hints with an affinity and a non-zero Score are counted: Score 0 means ’no signal’, not ‘zero utilization’”. It also notes the arithmetic detail — “we use integer division that truncates towards zero. For example, scores of 40 and 81 aggregate to 60.”
The score itself comes from a shared helper that all three resource managers use, so their numbers stay comparable:
// pkg/kubelet/cm/topologymanager/score.go:31
func UtilizationScore(allocatable, assigned int64) int64 {
if allocatable <= 0 {
return 0
}
return max(1, assigned*100/allocatable)
}max(1, ...) is the important bit. If anything is allocatable, the score is
at least 1 — so a score of 0 unambiguously means “no signal”, and 0 and 1
are not neighbouring values. Without the floor, a nearly-empty NUMA node
would score 0 and be indistinguishable from “no opinion”. The comment on
UtilizationScore also says the score “does not drive hint selection” — it
is informational. The preferred flag and the affinity width do the
deciding.
Picking the best permutation
The merger walks every permutation, merges it, and keeps the best:
// pkg/kubelet/cm/topologymanager/policy.go:336
func (m HintMerger) Merge(logger klog.Logger) TopologyHint {
defaultAffinity := m.NUMAInfo.DefaultAffinityMask()
var bestHint *TopologyHint
iterateAllProviderTopologyHints(m.Hints, func(permutation []TopologyHint) {
// Get the NUMANodeAffinity from each hint in the permutation and see if any
// of them encode unpreferred allocations.
mergedHint := mergePermutation(logger, defaultAffinity, permutation)
// Compare the current bestHint with the candidate mergedHint and
// update bestHint if appropriate.
bestHint = m.compare(bestHint, &mergedHint)
})
if bestHint == nil {
bestHint = &TopologyHint{NUMANodeAffinity: defaultAffinity, Preferred: false}
}
return *bestHint
}The comparison is a strict priority ordering. The very first check rejects any candidate whose affinity is empty — an empty affinity means the AND eliminated every node, so no allocation satisfies this permutation. Rejected here, a wider-but-viable hint beats a narrower-but-impossible one. Then preferred always beats non-preferred, in both directions:
// pkg/kubelet/cm/topologymanager/policy.go:213
func (m HintMerger) compare(current *TopologyHint, candidate *TopologyHint) *TopologyHint {
// Only consider candidates that result in a NUMANodeAffinity > 0 to
// replace the current bestHint.
if candidate.NUMANodeAffinity.Count() == 0 {
return current
}
// If no current bestHint is set, return the candidate as the bestHint.
if current == nil {
return candidate
}
// If the current bestHint is non-preferred and the candidate hint is
// preferred, always choose the preferred hint over the non-preferred one.
if !current.Preferred && candidate.Preferred {
return candidate
}Between two preferred hints, the tie-break is the affinity mask, and it depends on the policy:
// pkg/kubelet/cm/topologymanager/policy.go:184
compareNumaAffinityMasks := func(current, candidate *TopologyHint) *TopologyHint {
// If current and candidate bitmasks are the same, prefer current hint.
if candidate.NUMANodeAffinity.IsEqual(current.NUMANodeAffinity) {
return current
}
// Otherwise compare the hints, based on the policy options provided
var best bitmask.BitMask
if (policyName != PolicySingleNumaNode) && opts.PreferClosestNUMA {
best = numaInfo.Closest(current.NUMANodeAffinity, candidate.NUMANodeAffinity)
} else {
best = numaInfo.Narrowest(current.NUMANodeAffinity, candidate.NUMANodeAffinity)
}Narrowest is the default — pick the mask with fewer bits set. But with
the prefer-closest-numa-nodes policy option, and only for policies other
than single-numa-node, the tie-break becomes NUMA distance:
// pkg/kubelet/cm/topologymanager/numa_info.go:68
func (n *NUMAInfo) Closest(m1 bitmask.BitMask, m2 bitmask.BitMask) bitmask.BitMask {
// If the length of both bitmasks aren't the same, choose the one that is narrowest.
if m1.Count() != m2.Count() {
return n.Narrowest(m1, m2)
}
m1Distance := n.NUMADistances.CalculateAverageFor(m1)
m2Distance := n.NUMADistances.CalculateAverageFor(m2)
// If average distance is the same, take bitmask with more lower-number bits set.
if m1Distance == m2Distance {
if m1.IsLessThan(m2) {
return m1
}
return m2
}The rule is the hardware one: a NUMA node’s distance to itself is 10, to a
sibling node 20, and to a remote node 30 or more. Two masks of equal width
are compared by their average distance, and the mask whose nodes are
closest to the reader wins — allocating across distant NUMA nodes means
crossing the interconnect. This option requires cadvisor to report node
distances, and it is explicitly not supported on Windows
(pkg/kubelet/cm/topologymanager/numa_info.go:44).
The three policies
All three policies share the merge machinery; they differ in the admission decision.
Best-effort always admits. It is the fallback and it never rejects:
// pkg/kubelet/cm/topologymanager/policy_best_effort.go:41
func (p *bestEffortPolicy) canAdmitPodResult(_ *TopologyHint) bool {
return true
}Best-effort still computes a merged hint — the hint is passed to the resource managers so they try to align — but an unalignable pod is admitted anyway, with a CPU allocation that spans NUMA nodes and a memory allocation that is interleaved.
Restricted admits only a preferred hint:
// pkg/kubelet/cm/topologymanager/policy_restricted.go:39
func (p *restrictedPolicy) canAdmitPodResult(hint *TopologyHint) bool {
return hint.Preferred
}This is the important one. “Preferred” means every provider in the winning
permutation agreed on the same affinity mask — so the pod’s CPUs, memory
and devices all land on one NUMA node. A pod that cannot be aligned that way
is rejected with a TopologyAffinityError, whose message is deliberately
vague — “Resources cannot be allocated with Topology locality” — because
the kubelet cannot tell you which provider conflicted.
Single-numa-node filters first, then admits. It restricts the hint space to hints with exactly one bit set before merging:
// pkg/kubelet/cm/topologymanager/policy_single_numa_node.go:46
func filterSingleNumaHints(allResourcesHints [][]TopologyHint) [][]TopologyHint {
var filteredResourcesHints [][]TopologyHint
for _, oneResourceHints := range allResourcesHints {
var filtered []TopologyHint
for _, hint := range oneResourceHints {
if hint.NUMANodeAffinity == nil && hint.Preferred {
filtered = append(filtered, hint)
}
if hint.NUMANodeAffinity != nil && hint.NUMANodeAffinity.Count() == 1 && hint.Preferred {
filtered = append(filtered, hint)
}
}
filteredResourcesHints = append(filteredResourcesHints, filtered)
}
return filteredResourcesHints
}Note the hint.Preferred condition on both branches — only hints the
provider considers good survive the filter. A provider that says “I could
do nodes 0, 1 or 2, and I don’t care” is filtered out entirely, because its
preferred hints are the ones with a single bit.
And then there is the collapse back to nil:
// pkg/kubelet/cm/topologymanager/policy_single_numa_node.go:71
if bestHint.NUMANodeAffinity.IsEqual(p.numaInfo.DefaultAffinityMask()) {
bestHint = TopologyHint{NUMANodeAffinity: nil, Preferred: bestHint.Preferred}
}If the merged affinity came out equal to all NUMA nodes on the machine,
that means no provider actually constrained anything, so the hint collapses
to nil — “no topology preference”. The nil affinity is the signal
downstream that says “do not restrict this pod to a NUMA node”.
The provider normalisation. Before any of this, the hints are normalised. A provider that returns
nothing for a resource is not a veto — it is an absence of opinion. Three
distinct cases exist, and they are not the same: no hints at all for the
provider → a nil-affinity preferred hint (“I do not care”); no hints for a
specific resource → also nil-affinity preferred; and the provider has hints
for a resource but they are all empty → a nil-affinity hint that is not
preferred (“I have an opinion, and the opinion is that I cannot satisfy
this here”). That third case is why the merge’s preferred computation
distinguishes nil affinity from a non-preferred nil affinity. The
normalisation is filterProvidersHints
(pkg/kubelet/cm/topologymanager/policy.go:104).
The best-non-preferred tie-break. When two hints are both non-preferred, the merger picks the one that is
least bad — the one that covers the most NUMA nodes without exceeding what
the strictest provider can satisfy. maxOfMinAffinityCounts
(pkg/kubelet/cm/topologymanager/policy.go:156) computes that bound: for
each provider, the smallest affinity it offers, then the largest of those
across providers. The field comment spells out the intent — “prefer a hint
that has an affinity mask that includes all of the NUMA nodes from the
provider that requires the most NUMA nodes to satisfy its allocation.”
Wrapping up
The one thing to remember: the topology manager intersects providers by
bitwise-AND of their NUMA affinity bitmasks
(pkg/kubelet/cm/topologymanager/policy.go:71), enumerating the full
Cartesian product of provider hints recursively (pkg/kubelet/cm/topologymanager/policy.go:377). A Score
of 0 means “no signal”, not “zero utilization” — that is why
UtilizationScore floors at 1 whenever anything is allocatable, and why
aggregateHintScores skips zero-score hints.
The part people get wrong: that the topology manager is a soft
optimisation. Under restricted or single-numa-node, a pod that cannot
be aligned is rejected with a TopologyAffinityError (“Resources cannot
be allocated with Topology locality”), and the kubelet does not tell you
which provider conflicted. If a pod is stuck in ContainerCreating on a NUMA
machine, check whether the CPU manager’s exclusive-CPU reservation and the
device manager’s GPU placement can actually be satisfied on the same node
— not on two different nodes.
What to go do about it: the policy is set with the kubelet’s
--topology-manager-policy flag (none, best-effort, restricted,
single-numa-node) and the scope with --topology-manager-scope
(container or pod). Use pod scope if you want the whole pod’s
resources aligned together rather than each container separately. On
multi-NUMA hardware prefer single-numa-node with the
prefer-closest-numa-nodes option to get distance-aware tie-breaks (Linux
only — it needs cadvisor node distances). And watch the
container_aligned_compute_resources and
container_aligned_compute_resources_failure kubelet metrics: a rising
failure counter under restricted means the merge is producing empty
affinities, which almost always means the CPU and device managers are
disagreeing about which node to use.