Skip to content

refactor: clean up hardware discovery - #781

Draft
klihub wants to merge 44 commits into
containers:mainfrom
klihub:refactor/hardware-discovery
Draft

refactor: clean up hardware discovery#781
klihub wants to merge 44 commits into
containers:mainfrom
klihub:refactor/hardware-discovery

Conversation

@klihub

@klihub klihub commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Notes: So, this is a biggie... or rather a hugie. But I hope that it just looks scarier than it is in the end.

This is actually the really long head of a stream of changes I've been working on, the tail of which is an attempt to clean up some of the most offending/annoying bits in the topology-aware policy. That tail is still unfinished work, so it's not part of this commit set.

Anyway, as part of that work, I attempted a (maybe half-a{rs,ssist}ed) refactor of hardware discovery and abstraction we've accumulated over time into pkg/sysfs.System into a new pkg/lib/hardware.Machine abstraction, aiming for a smaller core API footprint + some convenience helpers for oft used functionality on top of it.

There is a lot of churn here in going through all the motions to

  • roll the new abstraction
  • add a sysfs.System-compatible wrapper on top of it
  • piece by piece get rid of direct sysfs.System usage, replacing it with direct usage of the new abstraction
  • deprecating sysfs.System in the end, once all users are gone

While going through all this some useful self-contained improvements fell out as side-effects, for instance

  • remove the obsolete and unused CPU controller
  • update balloons to do system discovery only once, not twice
  • fix a number of potential bugs we haven't hit or notice so far
  • enable unit tests which earlier could not be stubbed enough for running
  • fix broken unit tests and improve unit test coverage

A big part of that churn is in intermediate steps most of which content-wise falls out in the end when all is said and done. The justification for the numerous intermediate steps is to keep them small and self-contained, touching one logical piece at a time, and the primary goal being to allow us to run end-to-end test after each change and verify that they all pass, giving much bigger confidence that the (assisted) conversions did not miss anything obvious, introducing bugs/regressions.

This is now at a point, where it makes sense to start filing it as a set of smaller (logically or gh-physically) stacked PRs and start merging it from the bottom of the stack... but only with one important precondition: we are happy and content enough with the replacement for sysfs.System and confident that anything remaining we'll be able to handle with much smaller further changes.

If we're not there wrt. the new abstraction, then we should focus on review-hammering exactly those bits until we're happy with it. So my most important ask and primary intention for filing this is to get that reviewed, so I can rework this with any necessary changes and then move on/switch back to my original work item of the topology-aware cleanups.

I will try to file a stacked version of this, splitting it into multiple smaller logical pieces, I just haven't gotten the strength/time to do it now/today.

Notes: This draft is rebased on the pending DRA PR#774 from @bart0sh (using its latest version at the time of the rebase), so I can readily resolve conflicts and make any necessary related changes for the bits it brings in. The introduced DRA end-to-end test still pass.

bart0sh and others added 11 commits September 9, 2026 16:36
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Ed Bartosh <eduard.bartosh@intel.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@klihub
klihub force-pushed the refactor/hardware-discovery branch 2 times, most recently from 2b7acaf to a929c26 Compare September 10, 2026 23:16
The controller applied CPU frequency, uncore frequency and C-state
settings to the CPUs assigned to each class in control.cpu.classes.
It cannot have applied anything for some time: the assignments it
reads are only ever written by its own Assign(), which is uncalled
as the package is imported for its registration alone. So it read
its configuration, found no assignments, and enforced nothing.

pkg/resmgr/cpuclass does all of it now, in internal/cpufreq,
internal/cpuidle and internal/uncorefreq, and balloons has driven
that instead since cpuClasses were implemented. topology-aware has
never referenced this controller at all.

Leave the controllers configuration intact for now, simply giving
a deprecation warning if we encounter one.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
NewCpuTreeFromSystem discovered a sysfs.System of its own,
presumably only to explicitly force CPU and cache topology
discovery. But DiscoverSystem ignores flags and discovers
everything anyway, so that information was already present
in the instance the policy has in its options. So take it
as an argument instead and use the policy provided one.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
Test packages which ship a recorded sysfs tree as a tarball
unpack it into a local testdata directory when the tests run,
leaving thousands of generated files behind for the next
git add to potentially pick up. Ignore those directories.

One rule matching testdata anywhere replaces the three which
named a directory each, since every test package which does
this wants the same thing.

Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
System.CPU, System.Node and System.Package returned a nil
pointer inside a non-nil interface for an id the machine does
not have. Such a value is never == nil, so callers' nil checks
were dead code and the call after them would have panicked.

Return an untyped nil instead, and stop dereferencing without
checking in SingleThreadForCPUs and NodeDistance.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
Define CPUSet and AnyCPUSet interfaces for storing sets of CPUs.
Add a dense bitmask-based CpuMask implementation and a sparse
CpuSet wrapping the k8s.io cpuset.CPUSet. Add units tests for each.

Also, add a benchmark to check how the implementations perform in
various operations as the number of maximum and stored CPUs grow.

A nil set reads as the empty one. Every operation which does not
modify a set takes a nil receiver, and a nil operand, as empty,
which is Go's own rule for nil maps and slices and is what the
k8s cpuset value type gave its callers for free. Set, Clear and
Seal panic instead, saying what to do about it: no method can
allocate a set and store it back into the caller's variable, so
EmptyIfNil and Clone are how a caller gets one it can modify.

Assisted-by: copilot-cli
Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
Add pkg/lib/hardware, a leaner replacement for pkg/sysfs.
Discover reads a machine once and returns an immutable Machine
which is safe to share. Reading goes through an io/fs.FS rooted
at the host root, so WithRoot points discovery at a mounted host
filesystem and WithFS substitutes a recorded or synthetic one.

Handles are concrete, interned and nil-safe: a lookup for absent
hardware answers Valid() == false rather than returning a typed
nil whose methods then panic, as pkg/sysfs does. CPU sets are
libcpu.CpuMask, sealed, so they are safe to share and panic if
modified.

A Zone is a named set of CPUs at one Level. Zones deliberately
do not form a tree: whether a cluster sits inside a NUMA node or
spans several is a property of the machine, and which levels are
worth nesting differs per caller. Zones(level) is complete, and
SameZones says when two levels cut the machine the same way,
which is what the policies ask by hand today.

TopologyIndex flattens a Machine into a coordinate lookup table
for callers which consult it per allocation. The convenience
layer holds the groupings several consumers had each grown their
own version of: cache groups, logical clusters, thread rounding
and closest-node queries.

Unit tests cover the readers with fstest.MapFS, and discovery
against the recorded sysfs trees plus synthetic ones, asserting
invariants rather than fixed numbers.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
Reimplement pkg/sysfs on top of pkg/lib/hardware, with the same
names and the same signatures, so that a consumer moves over by
changing one import line. Most already import pkg/sysfs aliased
to "system", which is why the package is called that: for those
files even the alias stays.

This is a migration step and a proof. pkg/sysfs stays in the tree
beside it, so equivalence_test.go can run every method of both
against the same recorded topology and compare the answers. It
compares everything, including the methods no caller uses, since
those are the ones a reimplementation gets wrong unnoticed.
test-setup.sh unpacks the six recorded machines the other packages
already keep, rather than adding another copy of them here.

internal/dropin holds one body of code compiled against both
packages, so the compiler enforces that the surfaces match, and a
test enforces that the two files are the same code.

One pkg/sysfs quirk is copied deliberately: a NUMA node with no
CPUs of its own reports package 0, which is the zero value of a
field pkg/sysfs never assigns rather than a real package.
hardware.MemoryNode says -1 instead. SST lives here rather than
in hardware, and moves on to whoever still wants it.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
Swap pkg/sysfs for pkg/lib/hardware/system everywhere but
pkg/sysfs's own tests and the three places whose job is to
compare against it. Nothing else changes: the drop-in presents
the same names and signatures, so files which imported pkg/sysfs
aliased to "system" keep even the alias, and the rest keep the
name under an explicit one.

This extra intermediate step lets us do an extra round of
verification for the new implementation by running end-to-end
tests and checking that everything passes.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The topology was discovered in policy.NewPolicy, as a
sysfs.System. Discover it in NewResourceManager instead, as a
hardware.Machine, and hand it down through policy.Options to the
backends.

For now we keep both sysfs.System and hardware.Machine in the
policy backend options, but the former essentially come from the
latter through the compatibility wrapper. Once we have converted
everything to hardware.Machine, sysfs.System can be removed from
the options.

Note that the env overrides have to be asked for explicitly
here, as the drop-in asked for them on the callers' behalf.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The policy takes its topology from the Machine the resource
manager discovered, rather than from the pkg/sysfs interface
wrapped around it. cpuallocator, cpuclass and libmem still want
that interface, so they keep being handed the System from the
backend options; the policy itself no longer reads it.

toCpuSet and toCpuMask convert at the seam. The policy is
written in cpuset.CPUSet and stays that way for now. Once we
have removed all the remaining dependencies on sysfs.System,
we can update the internals here to use libcpu.CpuMask, which
should scale much better with the number of CPUs present.

Building the L2 cache level no longer iterates a map, so a
node's cache children come out in cache id order instead of a
different order on every run. The set of nodes is unchanged; on
all six recorded machines the tree matches what the previous
code built.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
WithSystemNodes read the NUMA nodes through the pkg/sysfs
interface. WithMachineNodes reads them from an already
discovered hardware.Machine instead, so the package no longer
depends on that interface at all.

A node hardware could not classify maps to TypeDRAM rather than
panicking as TypeForSysfs did on an unknown type. There is no
type here for "do not know", and ordinary memory is what such a
node arrived as before.

Capacities are read during discovery rather than being re-read
here. They cannot have changed, and a node whose meminfo cannot
be read fails discovery, so the error this used to return now
comes earlier.

The public set types are unchanged: this is about where the
topology comes from, not how sets are represented.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
NewCPUAllocator took a pkg/sysfs System. It takes an already
discovered hardware.Machine now, and the topology cache is built
from that and its TopologyIndex, so the package no longer depends
on that interface.

SST moves here with it. Topology discovery has nothing to do with
SST -- it is a property of the running platform rather than of its
shape -- and this is the only package which ever asked, so the
probing is now done here, once per machine rather than once per
package.

Clusters come from hardware.LogicalClusters/MergeSingleCoreClusters
call, which is equivalent to what the old sysfs interface reported.

The public set types are unchanged.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
ParseFileEntries has nothing to do with CPU or memory topology.
It parses a file of key and value lines, and lived in pkg/sysfs
only because that is where it was written.

Move ParseFileEntries to a pkg/utils/parse of its own, beside
pkg/utils/cpuset and pkg/utils/topology, so that any further
parsing helpers have somewhere to land without crowding the names
in pkg/utils. It loses the prefix its package now carries and is
parse.FileEntries. Its tests move with it. Fix an error with wrong
formatting verbs vs. arguments.

Also, remove the unused ParseEnabled in pkg/utils/parse.go instead
of moving it here.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
GetMemoryCapacity reads MemTotal out of /proc/meminfo and has
nothing to do with CPU or memory topology either. Move it from
pkg/sysfs to pkg/utils. Not to pkg/utils/parse with the file
entry parsing: it reads one fixed file rather than parsing what
it is handed, so a parse.MemoryCapacity would promise the wrong
thing.

pkg/kubernetes takes it from there now, which is all it wanted
from the topology packages. pkg/sysfs and the drop-in keep it,
forwarding, as they do the parsing. With this the drop-in no
longer imports pkg/sysfs for anything, which is what has to be
true before pkg/sysfs can go.

pkg/kubernetes has accessors of its own for the capacity the OOM
adjustment estimates are calculated against, and neither needs to
be exported. SetMemoryCapacity said why it was: so that the
estimator tests could vary the capacity, which they could only do
from outside because they were in kubernetes_test rather than in
the package. Move the test into the package, as cpuset_test.go
already is, and the setter can be unexported. Its GetMemoryCapacity
goes altogether, never having been called by anything.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The plugin discovered a pkg/sysfs System of its own. It discovers
a hardware.Machine instead and reads the topology from that, so it
no longer depends on that interface. It never set a sysfs root, so
the machine is discovered below "/" as before.

This also fixes which nodes "cpu-packages" selects. It asks each
memory node which package it is in, and a node with no CPUs of its
own has no package to answer with: pkg/sysfs said 0 for those, not
because they are in package 0 but because that is the zero value
of a field it never assigned. On a machine with HBM, CXL or PMEM
that meant every such node counted as package 0, so a container on
package 0 got all of them, including the ones attached to another
package, and a container on any other package got none.

Such a node now belongs to the package of the nearest node which
does have CPUs, which is what the kernel's distances say about
where the memory is and the only thing there is to go on. On the
topology the n6-hbm-cxl end-to-end suite describes, the two HBM
and two CXL nodes are each unambiguously nearer one of the two
packages, and that is what they are reported as now.

Nothing exercised "cpu-packages": it appears in the sample and
helm configurations as an example class and in no test, and the
two lines of comment above it were its only specification.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The handler and its cpufreq, pct and uncorefreq internals read the
topology through the pkg/sysfs interface. They take an already
discovered hardware.Machine now, so none of them depends on that
interface.

Two of them get smaller rather than just different.

pct declared a four-method subset of the interface for its
Allocator to depend on, of which it called two, and those only to
find one online CPU's frequency range. Its Sys is those two
methods now, which a Machine satisfies as it is.

uncorefreq's DiesForCpus walked every die of a CPU's package
looking for the die which contained the CPU. A hardware.CPU
answers with its own die, so it asks.

The tests keep their shape. pct's fake was a Sys, a CPUPackage and
a CPU built by embedding the interfaces and overriding a few
methods; it is two methods returning nothing now, which is all the
tests ever exercised through it. The handler's uncore tests
described a cpu -> (pkg, die) layout to a fake System; they write
the same layout out as sysfs and discover a machine from it, since
a Machine cannot be faked. Those tests exercise real discovery
now, and a new case covers DiesForCpus across several packages and
dies, which none of them did.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The tests faked the pkg/sysfs interface: a mockSystem with all 35
of its methods, plus a mockCPUPackage, a mockCPU and a
mockSystemNode, each built by embedding the interface and
overriding the few methods a test needed. That works only for an
interface, and the topology this policy is being moved onto is
concrete types.

Describe the machine instead and read it back through discovery.
synthMachine writes a topology out as sysfs and discovers it, so a
test says what nodes and CPUs it wants and gets the real thing.
A node with memory and no CPUs of its own comes back as PMEM or
HBM depending on its size, which is how the hardware package
classifies one, so the memory kinds a test asks for are expressed
as sizes rather than asserted into a fake.

The mocks were worth less than their 328 lines suggest. The hint
tests passed an entirely empty mockSystem, twice, to check that an
absent socket and an absent NUMA node yield nothing; a machine
with one CPU says the same. The coldstart test is the only other
user and has been skipped for a while, for the very reason this
addresses: it could not mock enough of the system. Its fixture is
kept, as nodes to synthesize, and it stays skipped here.

mockContainer, mockPod, mockCache and mockCPUAllocator stay. They
have nothing to do with topology and five test files use them.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
NewHint took the pkg/sysfs interface to resolve a hint's NUMA
nodes and CPUs against. It takes a hardware.Machine now, so the
package no longer depends on that interface.

Its one caller reaches the topology through the pool node it is
scoring, so node gains a Machine alongside its System. The policy
already has a machine in its backend options; the System goes when
the policy itself stops reading it.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The policy took its topology from the pkg/sysfs interface. It reads
the hardware.Machine the resource manager discovered instead, so it
no longer depends on that interface at all.

The questions it asks are all about one package or one die, and the
hardware package addresses dies, clusters and caches by their full
coordinates, since the kernel numbers them within their package.
topology.go turns the former into the latter and keeps the
conversions between the two set types in one place. The node
filters the interface offered become predicates on a memory node
there too.

Two things which had been interface values are gone from the pool
node. Its system.CPUPackage is the package zone, and what it was
asked for -- the NUMA nodes of the package, and of one of its dies
-- is a question about which nodes are local to a set of CPUs. Its
system.Node is a hardware.MemoryNode.

The tests which discovered a System beside a Machine now discover
only the Machine, there being nothing left to read the former.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The hint tests could only check that a socket or NUMA hint naming
something the machine does not have resolves to nothing. A TODO
beside them asked for the other half and said why it was missing:
the package could not be constructed, being a closed struct behind
an interface.

A machine can be described and discovered, so it can. Two sockets
of two CPUs, one NUMA node each, and a hint naming either resolves
to that socket's or that node's CPUs.

This is the case which would have caught getting the package
lookup wrong, as opposed to getting a miss wrong.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The metrics collector read the NUMA nodes and CPUs it reports
through the pkg/sysfs interface. It reads them from the machine
instead. It is the last thing in the tree which read that
interface.

Its memory figures come from one read of a node's meminfo now
rather than from a MemoryInfo which returned both, and a node it
cannot read reports zero capacity as well as zero usage. Reporting
the capacity discovery recorded beside a usage which could not be
read would be stating more than is known.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
BackendOptions carried the machine and, wrapped around the same
machine, the pkg/sysfs interface, for backends which had not been
moved over. All of them have, and the metrics collector was the
last thing in the tree to read it, so the field goes, along with
the policy's own copy and the sys root it needed.

pkg/sysfs and the drop-in over it are now referenced by nothing but
each other and their own tests.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
Nothing in the repository uses it any more. Mark it deprecated so
that tooling says so, and keep it for a release, since anything
outside the repository which uses it deserves one in which to move
over.

The notice names the replacement and says how the two differ, which
is more useful than pointing at the new package and leaving the
reader to discover that a machine is discovered once, that lookups
return handles which are never nil, that dies and cores are
addressed by their coordinates, and that SST is not there at all.

The drop-in stays too, and its own note is updated to say why: the
migration step it provided is done, but it is what compares this
package against the hardware package over the recorded machines,
and that comparison cannot outlive its subject.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The test was disabled a year ago because the mocked system it
ran against had no CPUs. It builds a machine of its own now, so
that reason is gone and the skip goes with it.

One thing was left over from the mocks. The memory type and
cold start preferences reached the policy through
GetResmgrAnnotation, which is not what it reads them with; they
are annotations scoped to a container, resolved by
GetEffectiveAnnotation. The mock pod's fields for the old form
have no users left and go too.

What the test asserts is what cold start promises: the
container starts on the PMEM node alone, and the DRAM node
joins it once the timer expires. Both halves fail if broken
deliberately, so it is worth having back.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The allocator took and returned k8s cpuset sets, so a caller
holding a mask converted on the way in and back on the way out,
and the allocator converted the machine's own sets a third time
to fill its topology cache. It takes masks now: the cache shares
the machine's sealed sets instead of copying them, and the set
algebra underneath every allocation is a good deal cheaper than
the map-based sets it replaces.

The from parameter loses its pointer. It was one because
allocating takes the allocated CPUs out of the set, which a mask
does natively, so the &-taking at the call sites goes with it.

A CpuMask has no usable zero value where a cpuset.CPUSet has, so
what used to read as an empty set now reads as a nil pointer: a
map with no entry for a core kind the machine does not have, a
struct field nobody assigned, an array of priorities with holes
in it. Those read through EmptyIfNil.

Nothing tested what allocating and releasing do to the set they
are given, which is the whole way a caller learns what is left,
so pin that. Releasing leaves the released CPUs in the set and
returns the ones kept; its debug message said the opposite and
now agrees with the code.

The two policies still keep their own CPU sets as cpuset.CPUSet
and convert where they call this.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The policy kept every CPU set as a k8s cpuset set and converted
to a mask wherever it asked the hardware package or the CPU
allocator something. Its own sets are masks now, so the CPU tree
the allocator walks per admission compares masks rather than
maps, and the conversions at the topology and allocator edges go
away.

What is left of the seam is where interfaces this policy calls
still take cpuset sets: the CPU class controller, the IRQ
affinity helpers, libmem's CPUSetAffinity, and the configuration
which parses an operator's cpuset string. Fifteen call sites, all
of them cold, against internals which no longer convert at all.

Nothing here modifies a set in place, so nothing aliases: every
operation builds a new mask, as it built a new cpuset before.
The sets which used to fall out of a zero value are now created
where they belong, in New and in the balloon constructors, since
a mask has no usable zero value. The two map lookups which
relied on that -- a CPU tree test asking about a balloon it has
not created yet, and its accumulator of allocation rounds --
read through EmptyIfNil.

Metric labels are unchanged: a mask spells itself the same way.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The policy kept its CPU sets as k8s cpuset sets and converted
wherever it asked the hardware package or the CPU allocator
something. Its own sets are masks now: the pool supplies, the
grants, the hint scoring, the isolated and sliceable sets.

Two things fall out of the type. takeCPUs took a destination set
which all ten of its callers passed as nil, and a pointer to a
source set so that it could write back what was left; the
destination goes, and the allocator takes what it allocated out
of the mask it is handed. A dry run, which works on a copy so
that the supply is left alone, now says that rather than
pointing at one.

pkg/utils/topology comes along. It scores hints for this policy
and has no other caller, so leaving it in cpuset sets would have
meant converting into it and back three times per check.
kubernetes.ShortCPUSet takes the libcpu interface, since all it
ever wanted was to spell a set. Its test turned out to compare
the unshortened form, so the shortening was never checked; with
the comparison fixed the function needed fixing too, as it
emitted segments out of order, and two of the expectations it
had been measured against were wrong themselves.

What is left of the seam is the CPU class controller, the IRQ
affinity helpers, and the configuration which parses an
operator's cpuset string.

The sets which used to fall out of a zero value are created
where they belong, in New. The tests which build a policy field
by field, or leave a set out of a table, say so through
EmptyIfNil.

Saved state is unaffected: a grant's CPUs are persisted as the
string they always were.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
An interrupt's affinity went in and out of this package as a k8s
cpuset set, so both policies converted at the call and back at
the return, having nothing but masks on either side.

Nothing about what reaches procfs changes: the cache still writes
the set's String() to smp_affinity_list and parses what it reads
back, and a mask spells itself the same way. The affinity maps
keep telling "no entry" from "empty set" with the two-value form,
which is what they always did.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The CPU class controller took k8s cpuset sets throughout: the
allowed set it is configured with, the CPUs a class is applied
to, the allocation intent it scores hints against, and the
candidate sets those hints hand back. Both policies had masks on
their side of every one of those calls.

Its own state and its inward edges go the same way. The sets pct
keeps per package, the ones cpufreq keeps per domain, and the
CPUs it reads out of goresctrl's SST types are all masks now, so
nothing is converted anywhere inside either.

That empties the seam. What is left of it in the policies is
libmem's CPUSetAffinity, and the configuration, which parses an
operator's cpuset string; balloons keeps one converter for each
and topology-aware one. The k8s type is still used to parse a
list of NUMA nodes in two places, which is what it is, and not a
set of CPUs.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The DRA plugin, the CPU class controller's DRA paths and the
policy-side adapter between them all took and returned k8s cpuset
sets. Everything on either side of those calls is a mask now, so
they take masks too.

That is the plugin's interfaces in deps.go, its claim state and
CDI writer, PickHpCpus, ReleaseHpCpus and AccountHpCpus down to
the punit sets pct keeps for DRA holds, the adapter which routes
between them, and the CPU sets the policy parses out of claim
attributes and container cpusets.

Nothing crosses the kubelet boundary as a set: a device is
published by name and a claim identified by UID, so what the
plugin says to the outside world does not change.

This is here rather than in the DRA work itself because the type
it converts to arrives with the commits below. Should the two be
reordered, this is the piece which moves.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
The tests which needed a system faked one by embedding the
sysfs.System interface. A hardware.Machine is a concrete type and
cannot be faked, so they describe the machine they want and read
it back through real discovery, which is what the rest of these
suites already do.

pct is the interesting one. Its Sys is two methods, both of which
a Machine has, so the fake goes entirely and the tests hand it
real machines with the package layouts the old fake described.
That turned out to be worth less than it looks: the only thing
pct reads out of a system is discoverTurboInfo, which no test
entered, so nothing depended on the layout at all. TestDiscover-
TurboInfo now does, which takes that function from no coverage to
most of it and gives the machines something to be right about.

The DRA tests take a machine each: the policy's own use
oneCpuMachine, which is already there, and the CPU class ones are
in an external test package and get a small machine of their own.
Both are minimal on purpose, since those tests drive the PCT
allocator through the Speed Select mock.

The tests which built a policy backend hand it a machine now
rather than a system, and the four which built one with neither
get the smallest machine there is, since NewPolicy refuses to
make a policy without one.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
@klihub
klihub force-pushed the refactor/hardware-discovery branch from a929c26 to 1e75d57 Compare September 11, 2026 06:53
libmem was the last consumer keeping a converter of its own.
A memory node holds the set of CPUs closest to it, and that
set is now a mask: WithMachineNodes hands the machine's own
set straight to NewNode instead of listing it out and
rebuilding it, and CPUSetAffinity takes anything which
satisfies the libcpu interface, since all it does is ask
what the set intersects.

NewNode still takes its own copy of the CPUs, and now seals
it. CloseCPUs hands that copy out, and a cpuset.CPUSet,
which this used to be, could not be modified by the receiver
either, so sealing is what keeps the node's set as immutable
as it was.

That retires balloons' toCpuSet. The only k8s set left in
that policy comes in from the configuration, so toCpuMask
stays.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
An Amount is a string, and the cpuset one is parsed on the
way in, so nothing about this reaches the wire: the CRD
schema for these fields is a plain string either way, and no
type in the config API holds a set of CPUs. The return type
of the parse method was the last seam, and both policies
stitched it with a toCpuMask of their own right at the call.

ParseCPUSet keeps its name, since it names what it parses,
which is what the field is, and matches the AmountCPUSet and
PrefixCPUSet around it. Only what it returns changes.

Both toCpuMask helpers go with it. The k8s parser stays in
both policies for the NUMA node lists, which are written in
cpuset syntax but are not CPUs.

The parsers agree on what they accept, empty string
included, so only a malformed cpuset reads differently.
libcpu names the offending list in its own error, and every
caller here already says which amount it was reading, so
ParseCPUSet no longer wraps what it gets: the message keeps
the two parts it had rather than growing a third.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Krisztian Litkey <krisztian.litkey@intel.com>
@klihub
klihub requested a review from askervin September 11, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants