Skip to content

domainmgr, zedmanager, zedagent: SMT/NUMA-aware CPU placement for pinned applications - #6335

Draft
rucoder wants to merge 22 commits into
lf-edge:masterfrom
rucoder:rucoder/core-pinning
Draft

domainmgr, zedmanager, zedagent: SMT/NUMA-aware CPU placement for pinned applications#6335
rucoder wants to merge 22 commits into
lf-edge:masterfrom
rucoder:rucoder/core-pinning

Conversation

@rucoder

@rucoder rucoder commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Places pinned application vCPUs on whole physical cores, deterministically, and
reports to the controller what the node can do and what each workload got.

Draft. It depends on API that is still under review (lf-edge/eve-api#155) and
on an adam bump so the new info fields survive ingest (lf-edge/adam#158). The last
commit here, pillar, evetest: build against the CPU-placement eve-api [DO NOT MERGE], points pillar and evetest at the fork carrying that API so the
branch builds and runs today; it must be dropped and replaced by an ordinary
make bump-eve-api before this can merge.

What a workload gets

A workload asking for cpu_policy=dedicated, full_pcpus_only is given whole
physical cores. With threads_per_core=2 both SMT siblings become vCPUs and the
guest is launched with an -smp topology that tells it truthfully which vCPUs
are siblings — software that places its own hot work cannot do so against a
fabricated topology. With threads_per_core=1 the sibling is parked and stays
consumed by that workload: a best-effort neighbour placed there would evict its
cache lines and contend for its execution units, which is the reason to ask for a
whole core in the first place.

Each vCPU is then pinned 1:1 to its host CPU. QEMU is already started paused, so
the pin lands while the guest has not executed. The guest-vCPU-to-host-thread
mapping comes from QMP query-cpus-fast, the only place it exists: QEMU does not
name its vCPU threads, and a domain's thread group also holds vhost_task helpers
that modern kernels create as user threads indistinguishable from vCPU threads by
name. Under io_placement=housekeeping the non-vCPU threads are kept off the hot
cores.

Why placement is planned for the whole set

Placement is computed over every workload the controller intends to run, not over
the workloads that happen to have activated. domainmgr only ever sees a
DomainConfig, which cannot exist before a workload's volumes are resolved, so
whichever workload was ready first used to be placed as though it were alone and
took cores the full plan would have assigned elsewhere — the same set of
applications landed differently on each boot, and after each image download race.

zedmanager knows the whole picture much earlier, so it publishes the demand set —
one aggregate object naming every workload intended to run with its CPU intent —
as soon as config is resolved. domainmgr plans over that. The result is that
placement depends only on the configured set: same set, same host CPUs, across a
reboot, across a staggered start, and in any restart order.

The plan is derived, never persisted. A stored assignment would become a second
source of truth that silently disagrees with the hardware after a CPU is
offlined, a NUMA node changes, or the config changes. Determinism comes from
ordering the batch by how constrained each workload is and breaking ties on
identity.

A workload that is configured but not activated releases its cores, exactly as an
assigned PCI device returns to the pool when its workload stops.

Failing closed

A placement that cannot be honoured is refused, never approximated: silently
handing back a running workload whose timing guarantees are gone is the worst
available outcome, because it looks like success. Each refusal carries a
machine-readable code (types/errorcodes.go) and a retry condition saying what
would change the answer, distinguishing a shortage a repack would fix from one
nothing would fix from a request that can never be satisfied.

Failures are terminal. The workload stays down until the config changes, which is
how EVE already treats a workload whose PCI device is unavailable; retrying would
start it at an arbitrary later moment on a placement nobody validated.

Reporting

api_capability advertises CPU_PLACEMENT_POLICY, without which a controller
cannot know the device honours the config fields; the device info carries the real
CPU topology, cache domains, kernel CPU isolation and per-pool CPU utilization
(with parked threads counted as consumed, so the reported headroom is true); and a
sub-optimally placed workload is reported as a WARNING-severity advisory rather
than an error, since it runs normally and whether the improvement is worth a
restart is an operator's judgement.

Testing

  • make -C pkg/pillar test passes (the CPU placement paths are covered by unit
    tests in cputopology, cpuallocator, types, cmd/domainmgr, cmd/zedagent,
    cmd/zedmanager and hypervisor).
  • Seven evetest e2e tests, one per commit, all passing on a KVM device with a
    real SMT topology: one-per-core and whole-core-SMT placement; three
    differently-policied applications sharing a node on disjoint cores; stability
    across a reboot, a delayed start and a reverse restart order; a parked sibling
    being withheld from every other workload and from the reported free capacity; a
    fragmented node reporting needs_repack and a repack really letting the
    workload run; and every class of unsatisfiable request being refused with its
    own error code, never booting, and leaving the node's dedicated pool unchanged.
  • The e2e framework gained the means to ask for a device with real SMT siblings
    and to observe host topology, kernel isolation, cgroup cpusets and per-vCPU
    affinities.

Two pre-existing bugs are fixed along the way: zedmanager dropped an application's
start_delay_in_seconds whenever its config arrived before the controller-status
message that set the delay base time, and the housekeeping IO placement could draw
CPUs from a set that included cores already promised to another workload.

rucoder added 15 commits August 17, 2026 17:34
Adds a standalone package that reads the machine's socket, physical-core,
SMT-sibling, NUMA and L3 structure straight from sysfs, in pure Go with no
CGO and no external topology library. Device classes EVE targets often ship
neither, and the native dependency previously considered for this proved
fragile on client and non-server SKUs.

SMT siblings are identified by a shared (socket, core_id) key, which is
authoritative on every architecture we target. Grouping by a cache id would
be wrong: on Intel hybrid parts an efficiency-core module exposes one shared
L2 across four distinct physical cores with no SMT, which such a key would
model as a single four-thread core.

Discovery degrades to a flat model rather than failing when sysfs cannot be
read, so a caller always has a usable topology and simply loses the locality
guarantees it cannot substantiate.

The package deliberately depends on nothing else in pillar so the allocator,
the hardware inventory and a future cluster-side consumer can share it.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Replaces the CPU allocator with one that understands the machine's topology,
so a workload asking for dedicated CPUs can be given whole physical cores in
a NUMA-local, SMT-aware way rather than an arbitrary set of logical CPUs.

Placement is computed for the whole set of pinned workloads at once and
ordered by how constrained each one is -- whole-core-SMT first, since it can
only use a core that really has two hardware threads and on a hybrid or
SMT-disabled machine most cores cannot, then one-per-core, then anything
thread-granular. The result is therefore a function of the request set rather
than of the order requests arrive in. Allocating incrementally meant whichever
workload activated first won the scarce cores, so a flexible workload could
take the only SMT-capable core and leave a workload that needs one unplaceable
-- and the same set of workloads could land differently on each boot.

Plan does not mutate the allocator: the caller reserves an assignment when the
workload actually starts, which is what lets a workload that has not started
yet, or starts late, still claim the CPUs set aside for it.

Score ranks an assignment by what actually costs performance -- NUMA nodes
spanned, then last-level caches -- and deliberately not by which CPU indices
were used. Many assignments share the best score, so comparing indices would
report a workload as mis-placed merely because its first-choice CPUs were
taken, and demand a restart that changes nothing.

A core is withheld when any of its siblings is reserved for EVE. That costs
capacity, so the shortage message says as much: handing out a core whose
sibling runs housekeeping would reintroduce exactly the interference whole-core
placement is bought to remove. The shortage also carries how many cores were
needed against how many were free, so a caller can explain the refusal without
computing a second, differently-filtered count.

PoolUtilization reports the housekeeping, dedicated and isolated pools with
both their CPU sets and their whole-core counts. Free threads alone answer
"will it fit?" wrongly: threads left on partially-owned cores cannot satisfy a
request for whole cores.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Introduces the device-internal representation of a workload's CPU placement
intent, mirroring the Kubernetes CPUManager and Topology Manager terms the
controller API uses: cpu policy, full-pcpus-only, threads per core, NUMA
policy, IO placement, isolation tier and disruption policy.

Intent is kept deliberately separate from the allocator's vocabulary. Intent
says what a workload needs; the allocator decides which host CPUs it gets.
Keeping them apart means the wire format never dictates the placement
mechanism, and it lets the two sources of intent -- the controller and the
operator-editable /persist override -- resolve into one representation.

The zero value means no policy was sent, so VmConfig.CPUsPinned alone keeps
deciding and behaviour is unchanged for a controller that sets none of this.

DomainStatus gains the resulting guest topology, the per-vCPU host CPU
mapping, the emulator CPU set and the achieved placement quality. Quality is
status rather than an error: a sub-optimally placed workload runs normally,
and whether the improvement is worth a restart is a judgement for an operator.

Adds the error-code registry reported alongside the free-text description, so
a controller can distinguish conditions that need different responses -- a
shortage a repack would fix, one nothing would fix, and a request that can
never be satisfied -- without pattern-matching prose. ErrorDescription carries
the code and a retry condition through to the wire.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Realizing a whole-core placement on QEMU/KVM has three parts.

The guest is launched with an -smp topology computed from the assignment, so
software inside it sees the real SMT structure and can place its own hot work
on non-sibling cores. A poll-mode datapath deliberately runs a worker on each
sibling; without a truthful topology it cannot tell which vCPUs share a core.

Each vCPU thread is then pinned 1:1 to its assigned host CPU. QEMU is already
started paused, so the vCPU threads exist while the guest has not executed and
there is no pre-pin race. The guest-vCPU-to-host-thread mapping comes from QMP
query-cpus-fast, which is the only place it exists: QEMU does not name its vCPU
threads unless started with debug-threads=on, and a domain's thread group also
holds vhost_task helpers that modern kernels create as user threads in that
same group, indistinguishable from vCPU threads by name or by flags. The pin is
applied after the cgroup cpuset has been written and before the guest is
released, so it is not undone by the cpuset.

Under io_placement=housekeeping the non-vCPU threads are pinned off the hot
cores, so device emulation cannot steal cycles from a busy vCPU. A virtio-blk
iothread keeps disk IO off the main loop.

Kubevirt reports that it cannot bind individual vCPUs. The capability is
separate from plain cpuset confinement, because a hypervisor that can confine a
domain to a set of CPUs may still be unable to bind one vCPU to one CPU or to
advertise the resulting topology -- and accepting a whole-core request it cannot
apply would report the workload as optimally placed while nothing was pinned.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The CPU inventory emitted one entry per physical core with the core id in the
field meant for a logical CPU id, no frequency and no topology. That is worse
than incomplete: the ids were not the ones CPU affinities are expressed in, and
the SMT structure -- the thing a consumer reasoning about CPU placement needs
most -- was absent entirely.

It now reports one entry per logical CPU carrying its socket, physical core,
NUMA node and L3 domain, taken from the same topology discovery the allocator
uses so the report and the behaviour cannot drift, plus base and maximum
frequency where the kernel exposes them.

Cache domains are reported with the set of CPUs sharing each one, which is what
tells a consumer which workloads would contend for the same cache. The per-CPU
sysfs views are collapsed into one entry per real cache instance.

Kernel-level CPU isolation is reported separately as a node fact rather than a
CPU one, and read from sysfs rather than parsed out of the command line, so it
describes what the kernel is actually doing. The two differ when a parameter is
malformed or capped, which is exactly when a consumer needs the truth.

Topology discovery failing degrades to the previous flat listing instead of
failing the whole inventory, which is still useful on a platform whose sysfs
layout we cannot read.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Maps the VmConfig CPU placement fields onto the device-internal intent and
derives CPUsPinned from it. Two properties matter. An unrecognised enum value
from a newer controller degrades to "no preference" rather than being rejected,
which is safe because a controller is expected to gate on the capability
reports below. And a dedicated policy is self-sufficient: it implies pinning on
its own, so a workload no longer has to set the legacy pin_cpu flag as well for
its CPUs to actually be pinned. With no policy sent, pin_cpu decides exactly as
before.

Advertises API_CAPABILITY_CPU_PLACEMENT_POLICY. Until this is reported a
controller has no way to know the device honours the placement fields at all,
which is precisely the failure the existing enforced-network-interface-order
capability guards against, and it is what makes the fail-open behaviour above
sound.

Reports the node's CPU pool utilization on device info, per pool, with both the
CPU sets and the whole-core counts, so a controller can answer "will this fit?"
before a deploy and explain a shortage after one. This is dynamic state, so it
rides the change-driven message rather than the cached hardware inventory.

Surfaces a sub-optimal placement per application as a non-fatal advisory. It is
converted to an ErrorInfo only at the wire, and never placed in the status error
fields, because those are read as fatal in several places and a workload whose
placement is merely improvable must not be torn down for it.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
CPU placement has to be a function of the configured set of pinned workloads,
but domainmgr only ever sees a DomainConfig, and a DomainConfig cannot exist
before a workload's volumes are resolved -- it carries the disk list. So during
boot, or while images download at different rates, whichever workload was ready
first was placed as though it were alone and took cores the full plan would have
assigned elsewhere. The same set of workloads landed differently on each boot.

zedmanager knows the whole picture much earlier: it holds every
AppInstanceConfig, it owns the profile resolution that decides what is meant to
run, and it is the component that withholds the DomainConfig in the first place.
It now publishes that demand set -- one aggregate object naming every workload
intended to run with its CPU intent -- as soon as the config is resolved, with
no dependence on volumes.

The set is published as a single object rather than one item per workload on
purpose. Per-workload items would leave the consumer planning over whatever had
arrived so far, which is the same ordering bug on a faster topic. An empty set
is published explicitly, so "no pinned workloads" is distinguishable from
"zedmanager has not spoken yet".

A workload that is configured but not activated is left out: its cores belong to
the workloads that do run, exactly as an assigned PCI device returns to the pool
when its workload stops.

Also fixes a pre-existing bug this work depends on. The start moment of a
delayed workload was computed from a base time set only when zedmanager
processed a controller-status message, and the app config regularly won that
race -- leaving a start moment derived from the zero time, which is always in
the past, so the delay was silently dropped and never recomputed. The base time
is now established on first use, so a workload created before that message
arrives gets the same start moment as one created after.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
…told

Placement now runs over the whole demand set published by zedmanager rather
than over whichever DomainConfigs have arrived. The result for a given set of
workloads is therefore the same regardless of the order they were configured,
started or delayed in, and the same across a reboot -- properties an operator
depends on when a workload's performance was validated against a specific
placement.

The plan is derived, never stored. Persisting an assignment would create a
second source of truth that can disagree with the hardware after a CPU is
offlined, a NUMA node changes or the config changes, and the failure mode of
stale placement data is silent and hard to diagnose. Determinism comes from
ordering the batch by how constrained each workload is and breaking ties on the
workload's identity, so recomputation reproduces the same answer.

A whole-core request consumes every thread of its cores. When only one thread
per core is wanted, the sibling is parked -- held by that workload and offered
to nobody. This is the point of asking for a whole core: a best-effort workload
running on the parked sibling would evict the cache lines and contend for the
execution units the request exists to protect. Parked threads are reported as
consumed in the pool utilization rather than as spare capacity, so a controller
sees the true remaining headroom.

Placement failures are terminal. A workload that cannot be placed stops with an
error naming the cause -- a shortage, a shortage a repack would fix, or a
request nothing could satisfy -- and stays stopped until an operator changes the
config, which is how EVE already treats an unavailable PCI device. Retrying
would silently place the workload the moment some unrelated workload happened to
release cores, at an arbitrary time, with no operator awareness that its
performance envelope had changed.

Two long-standing behaviours are corrected. The operator-editable override on
/persist can now enable pinning for a workload the controller did not pin, not
only disable it, which is what makes it usable for on-device diagnosis. And
housekeeping IO placement no longer draws its CPUs from a set that could include
cores already promised to another workload.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
…bserve it

A test cannot assert anything about CPU placement on a node whose CPU topology
it does not control: with a flat single-thread-per-core VM every placement
policy looks alike, and whole-core, one-per-core and parked-sibling behaviour
are indistinguishable. The device requirements gain a threads-per-core knob, so
a test can ask for a node with real SMT siblings, and the QEMU and libvirt
providers derive the -smp topology from the requested CPU count and that knob
through one shared helper -- the two providers disagreeing about what a
requirement means would make results depend on which one ran.

On the observation side, tests get the node facts CPU placement work needs:
the host's socket/core/sibling topology, which CPUs are online, which the kernel
isolated, and the kernel command line, so an expectation can be stated in terms
of what the node actually is rather than hard-coded numbers.

Per-workload facts come from QMP over the existing SSH transport. The
guest-vCPU-to-host-thread mapping is only available there -- QEMU does not name
its vCPU threads, and a domain's thread group contains helper threads that
cannot be told apart from vCPU threads by name. A QMP call is also a
point-in-time question with a definite answer, which is what an assertion wants,
where waiting for a log line is a race dressed up as a check. The call is
bounded by closing the connection from a timer, since deadlines are not
supported on SSH channels.

The application config gains the CPU placement policy fields and a start delay.
The delay exists to test the property that matters most here: that placement
does not depend on the order workloads happen to start in.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Covers the three placement shapes a controller can ask for, each asserted
against the host's real topology rather than against expected CPU numbers.

One-per-core: as many distinct physical cores as vCPUs, every vCPU pinned to
exactly one host CPU, no two vCPUs sharing a core. Whole-core-SMT: both siblings
of each core become vCPUs, and the guest's own view of its topology matches how
it was actually pinned -- a guest told it has siblings that are not siblings will
co-schedule work that then contends.

The multi-app case is the one that catches interference: a whole-core-SMT app, a
one-per-core app and a best-effort app deployed together must land on disjoint
CPUs and disjoint physical cores, with housekeeping CPUs still available to the
system. A test on any single app in isolation would pass while the allocator
handed the same core to two workloads.

Each reachable app needs its own forwarded edge-node port, since the port
belongs to the node.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The property an operator relies on is that a validated placement stays put. This
test asserts it three ways on one unchanged set of applications: across a reboot,
across a staggered start where one app is deliberately delayed, and across a
restart in the reverse order. Every vCPU must land on the same host CPU each
time.

Order independence is what makes reboot stability real rather than incidental.
Boot orders vary with image download times, network readiness and configured
start delays, so a placement derived from arrival order would be reproducible
only by luck. The delayed-start case exercises exactly the window in which a
workload's config is known but its domain does not exist yet.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
When a workload asks for one thread per physical core, the other thread of each
of its cores is deliberately left idle. That thread is consumed, not free: a
best-effort workload placed on it would evict the cache lines and compete for
the execution units the request exists to protect, which is the whole reason for
asking for a whole core.

Asserted three ways, because each alone is insufficient: no other workload gets
the parked thread in its cpuset, the node does not advertise it as free capacity
to a controller, and nothing is ever observed executing on it. The middle one is
what stops a controller from confidently over-committing the node.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
A node can have enough free threads for a whole-core workload and still not have
a single free whole core, because earlier thread-granular workloads left one
thread busy on each. The two shortages call for opposite responses: nothing will
help the first, while rearranging existing workloads would resolve the second,
and only the workloads' owner can decide whether that disruption is acceptable.

The test fragments the node deliberately, confirms the refusal carries
cpu.placement.needs_repack rather than a plain shortage, and then repacks and
confirms the workload really does run -- so the advice the code gives is
demonstrated to be true, not merely plausible.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
A request that cannot be honoured must be refused, not approximated. Silently
falling back to a weaker placement would hand back a running workload whose
timing guarantees are gone, with nothing in the reported state to say so -- the
worst outcome available, because it looks like success.

Each class of unsatisfiable request is checked separately with its own error
code, since a controller needs to distinguish a request that is malformed from
one the node cannot support from one it merely has no room for. The workload must
never boot, and the node's dedicated CPU pool must be unchanged afterwards: a
refused request that leaked cores would shrink the node's capacity with every
retry.

The refusal also has to persist. A placement failure that healed itself as soon
as some unrelated workload released cores would start the workload at an
arbitrary moment with a placement nobody validated, so this asserts the workload
stays down until its config changes -- the same way EVE already treats a workload
whose PCI device is unavailable.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The device-side code in this branch needs API that has not landed upstream yet:
the VmConfig CPU placement fields, the CPU topology and capability reporting on
device info, ZInfoDevice.cpu_pools, API_CAPABILITY_CPU_PLACEMENT_POLICY and
ErrorInfo.error_code. Without them nothing here compiles, so this commit points
pillar and evetest at the fork carrying the proposed API (lf-edge/eve-api#155)
through a replace directive, and vendors it.

This commit exists only so the branch can be built, run and reviewed while the
API is under discussion. It must be dropped and replaced by an ordinary
`make bump-eve-api` once the API lands in lf-edge/eve-api: a replace directive
pointing at a personal fork breaks dependency tracking and SBOM/licensing, and
is never acceptable on master.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Committed only so edits to it are reviewable as diffs while the design is being
revised against the implementation and against review comments. Drop this commit
before the branch is proposed for merge -- the document is a working design note,
not repository content.

Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
A whole-core-smt request on a node where no physical core presents two
hardware threads -- SMT switched off in the platform firmware, or a part
that has none -- is unsatisfiable no matter what else happens on the
node. The allocator already says so: it returns InvalidRequest with
TopologyUnsupported set and a message naming the thread counts the node
does have.

Nothing read that flag. The status alone mapped to cpu.policy.invalid,
so the controller was told the workload's configuration was wrong when
it was correct, and the generic retry condition for that code told the
operator to change a policy that needs no changing.

Map TopologyUnsupported to cpu.topology.unsupported, and attach the
condition that actually clears it: enable SMT in firmware, or ask for
threads_per_core=1. The code's generic condition speaks about the
hypervisor, which is the other way to reach it, so this site states its
own.

Signed-off-by: Mikhail Malyshev <mikem@zededa.com>
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
…report

A node booted with isolcpus reported an isolated CPU pool to the
controller and did nothing else with it. No workload could ask for those
cores, and nothing kept other workloads off them, so an operator who
carved out cores on the kernel command line got a number in the device
info and no change in behaviour. Meanwhile isolation_tier=hard was
refused on every node unconditionally, while the retry condition told the
operator to "deploy it on a node booted with CPU isolation" -- advice
that could not work.

Isolated cores are now allocated capacity with exactly one claimant. The
placer holds the isolated set and withholds those cores from every
request that did not ask for isolation, including the thread-granular
path; isolation_tier=hard turns into a hard constraint that can only be
served from them. Not a preference either way: handing them to whoever
activated first would spend the operator's isolation on a workload that
never wanted it and leave the one that needs it unplaceable, and placing
a workload that asked for isolation on an unshielded core would tell it
it is protected from housekeeping it is still exposed to.

The other half is the housekeeping set. isolcpus keeps the scheduler's
load balancer off a core but honours an explicit affinity, so the cpuset
that confines the non-pinned workloads and the emulator threads has to
exclude the isolated CPUs or the kernel will happily run them there. An
isolcpus that swallows every free CPU falls back to the unfiltered set,
loudly: that is a misconfiguration, and an empty cpuset would take down
every non-pinned workload on the node.

Three refusals accompany it, all of which would otherwise be silent
downgrades. A hard tier that is not whole-core cannot be served at all --
on a shared core the kernel still schedules the sibling. A hard tier on a
node whose kernel isolates nothing is refused naming isolcpus, since the
node cannot acquire an isolated set while running. And an ordinary
shortage now says how many cores were withheld for isolation, so an
operator is not left comparing "insufficient" against CPUs that look
idle.

The pool report follows: the isolated set comes from the placer rather
than from a second, independently supplied list, and housekeeping
freeness excludes isolated CPUs -- that pool answers "will an ordinary
workload fit?", which those CPUs cannot serve.

Signed-off-by: Mikhail Malyshev <mikem@zededa.com>
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
Boots a device with isolcpus/nohz_full injected through /config/grub.cfg,
mirroring EVE's own set_isolcpus grub hook, and deploys three workloads
onto it: one asking for hard isolation, one asking for whole cores
without it, and one best-effort.

The assertion is in both directions, because either half alone is not the
guarantee: the hard-tier app's CPUs must lie wholly inside the kernel's
isolated set, and the other two must touch none of it -- neither in what
is dedicated to them nor in the cpuset they may run in. The node must
also stop advertising those CPUs as free housekeeping capacity, or the
next workload the controller places lands there anyway.

The isolated set is read from sysfs, not from the command line: a capped
or malformed isolcpus parses fine and isolates nothing, which would make
every later phase pass vacuously. The CPU list has to be picked before
the device exists, so the test verifies rather than trusts that it covers
whole physical cores, and says so with the real topology when it does
not.

Signed-off-by: Mikhail Malyshev <mikem@zededa.com>
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The controller cannot yet express a CPU placement policy: the UI has a
"CPU pinning" checkbox and nothing else, so the only thing on the wire is
the legacy VmConfig.pin_cpu flag. That flag means "dedicated with default
allocation", and the default allocation is thread-granular -- the
workload gets CPUs of its own, but they may be single SMT threads of
cores it shares with something else, and the guest is shown a flat
topology with no sibling information.

That is exactly the placement the target workload cannot use. It runs a
poll-mode worker on each sibling of a physical core, so it needs whole
cores it owns outright and a truthful guest topology to find the sibling
pairs in.

Make pin_cpu on its own mean what the policy-aware controller will
eventually send for it: cpu_policy=dedicated, full_pcpus_only,
threads_per_core=2. N vCPUs then occupy N/2 whole physical cores, both
siblings of each, and the guest sees threads=2. The thread count a
workload gets is unchanged; which cores those threads sit on is not.

Precedence is untouched. A controller that does send a policy still wins,
including one asking for thread-granular placement, and the
operator-editable /persist/pinning override still selects one-per-core.

No fallback is added for the cases this cannot serve: an odd vCPU count
fails with cpu.policy.odd_vcpu and a node without SMT with
cpu.topology.unsupported. Placing such a workload thread-granularly
instead would report it as pinned while giving it none of what pinning
was turned on for.

TEMPORARY -- revert this commit once the controller sends the policy
fields. It contradicts the API's documented meaning of pin_cpu for as
long as it is in the tree.

Signed-off-by: Mikhail Malyshev <mikem@zededa.com>
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
The kernel-isolated CPUs are handed out only to a workload that asks for
isolation_tier=hard, and a controller that can only set pin_cpu cannot
ask. On a node booted with isolcpus that leaves those cores unreachable
by any workload: advertised in the pool report, usable by nobody.

Add cpu.pinning.use.isolated, a node-level boolean. With it set, a
whole-core pinned workload is placed on the isolated cores without the
controller having to express anything. Node-level rather than per-app
because the isolated set is itself a node-level boot decision, and it can
be flipped from the controller on a node that is already running.

Only whole-core workloads are promoted: kernel isolation means nothing on
a core whose sibling the kernel still schedules freely, which is the rule
the hard tier is already held to.

The switch set on a node whose kernel isolates nothing is deliberately
not an error. Unlike an explicit isolation_tier=hard -- a guarantee a
workload must not run without -- this is an operator preference for the
whole node, most likely set before the reboot that puts isolcpus on the
kernel command line. Failing every pinned workload until that reboot
would be a worse answer than placing them normally and warning.

It takes effect for workloads placed from then on. A running workload
keeps its CPUs: its vCPU threads are already pinned and its guest was
told a fixed topology at launch, so moving it means restarting it.

Signed-off-by: Mikhail Malyshev <mikem@zededa.com>
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
A pinned workload is only correctly placed if several things hold at
once, and each of them lives in a different place: the allocator's
record, the kernel's per-thread affinity masks, the host's SMT sibling
map, the cgroup cpusets of every other workload, and what the guest
believes its own topology is. Checking them by hand takes a dozen ssh
commands and it is easy to confirm the claim you wanted rather than the
one the machine supports.

This asserts them instead. Nine proofs per workload: one host CPU per
vCPU; the guest topology multiplies out to the vCPU count; each vCPU pair
sits on the two SMT siblings of one real core; the core count matches
what the guest is told; every vCPU thread is pinned to exactly one CPU;
no other workload's cpuset overlaps; EVE's own services are disjoint; no
user-space process outside the workload may run there; and the CPUs are
either wholly inside or wholly outside the kernel-isolated set. Plus a
guest-core -> host-core cross-map, which is the one that catches an SMT
topology the guest was lied to about. Non-zero exit on any failure, so it
works as a gate.

It also checks that isolcpus is sibling-complete. A range written for an
SMT-disabled boot ("2-7") isolates one thread of six cores once SMT is
on: those cores are then usable by nobody -- withheld from ordinary
requests because they touch the isolated set, yet not fully isolated, so
they cannot serve the hard tier either. That failure is otherwise silent.

All parsing happens on the machine running the script; the node is only
asked for small file reads. Deliberately: EVE's control plane is confined
to CPU 0 with sshd in the same cgroup, so log scraping over ssh starves
zedbox and the watchdog reboots the node.

Signed-off-by: Mikhail Malyshev <mikem@zededa.com>
Signed-off-by: Mikhail Malyshev <mike.malyshev@gmail.com>
if err != nil || n < 0 {
return 0, false
}
return uint(n), true
if n < 0 {
return 0, fmt.Errorf("parsing %s: unexpected negative value %d", path, n)
}
return uint(n), nil
}
id = uint64(shared[0])
}
k := key{level: int(level), cacheType: cacheType, id: uint32(id)}
}
id = uint64(shared[0])
}
k := key{level: int(level), cacheType: cacheType, id: uint32(id)}
if !seen {
size, _ := readCacheSize(filepath.Join(indexDir, "size"))
domain = &cacheDomain{
Level: int(level),
domain = &cacheDomain{
Level: int(level),
Type: cacheType,
ID: uint32(id),
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.34653% with 498 lines in your changes missing coverage. Please review.
✅ Project coverage is 26.31%. Comparing base (f58ca01) to head (48c2750).
⚠️ Report is 50 commits behind head on master.

Files with missing lines Patch % Lines
pkg/pillar/cmd/domainmgr/domainmgr.go 44.20% 138 Missing and 16 partials ⚠️
pkg/pillar/cpuallocator/placement.go 79.94% 60 Missing and 12 partials ⚠️
pkg/pillar/hypervisor/pinning.go 0.00% 61 Missing ⚠️
pkg/pillar/cmd/domainmgr/pinningconfig.go 67.70% 20 Missing and 11 partials ⚠️
pkg/pillar/cputopology/sysfs.go 82.27% 15 Missing and 13 partials ⚠️
pkg/pillar/hardware/cpudetails.go 81.08% 12 Missing and 9 partials ⚠️
pkg/pillar/cmd/domainmgr/cpuplan.go 90.66% 9 Missing and 5 partials ⚠️
pkg/pillar/cmd/zedmanager/zedmanager.go 41.66% 14 Missing ⚠️
pkg/pillar/cmd/zedagent/zedagent.go 0.00% 13 Missing ⚠️
pkg/pillar/cpuallocator/plan.go 82.85% 10 Missing and 2 partials ⚠️
... and 13 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6335      +/-   ##
==========================================
+ Coverage   24.66%   26.31%   +1.64%     
==========================================
  Files         514      537      +23     
  Lines       94151    97749    +3598     
==========================================
+ Hits        23227    25725    +2498     
- Misses      69059    69834     +775     
- Partials     1865     2190     +325     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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