You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The only CPU control cocoon applies today is the vCPU count: CH gets --cpus boot=N,max=<host cores> (hypervisor/cloudhypervisor/args.go) and nothing constrains the VMM process on the host side. vCPU count bounds guest-code parallelism, not host consumption:
VMM I/O threads run outside the vCPU threads: virtio-blk/net processing, io_uring io-wq kernel workers (accounted to the VMM process), vmm/api threads.
KVM halt-polling (halt_poll_ns, default 200us) burns host CPU in vCPU threads even while the guest idles.
There is no floor either: under host contention nothing guarantees a VM any share.
Neither backend can do this natively. CH --cpus offers boot/max/topology/affinity only — upstream expects the embedder to use cgroups (libvirt/Kata do exactly that). FC has cgroup support only via the jailer, which we do not run (#83), and FC is headed to maintenance tier (#161). The mechanism therefore belongs in cocoon, backend-agnostic, at the shared VMM spawn path.
Baseline evidence (measured)
Bare-metal 16-core testbed, cocoon master-07f1d69, CH v54.0.0. One VM, 1 vCPU / 2 GB / no NIC / COW disk direct=on, num_queues=1; whole-process %CPU via pidstat -t, 15–20 s averages:
guest workload
VMM process %CPU
breakdown
idle
0.1%
halt-polling negligible on this host
1× sha256sum (vCPU pegged)
100.0%
vcpu0 alone — the vCPU cap works exactly as expected
+ 1× dd O_DIRECT write loop
111.1%
vcpu0 99.6 + disk queue thread 11.2 + io-wq
+ 6× concurrent dd loops (4 write, 2 read)
113.5%
vcpu0 99.9 + disk queue 13.5 + io-wq
The >1-core claim is real and measured — and this is the floor case: one vCPU means one virtio queue per disk (num_queues=cpuCount) and a single guest thread issuing I/O. Queue threads scale with vCPU count and net-attached VMs add net queue processing on top, so the absolute overhead grows with exactly the workloads we deploy (multi-vCPU Windows agents doing real network I/O).
Proposal: per-VM cgroup v2 scope, raw knobs
At start, create a scope at a deterministic path under a configurable parent (default <cgroup2 root>/cocoon.slice/vm-<id>.scope) and spawn the VMM directly into it via clone3 CLONE_INTO_CGROUP (SysProcAttr.UseCgroupFD/CgroupFD, behind a Linux-only helper so the Darwin build stays valid) — no post-fork attach race, zero cost on the claim path. Every VMM thread inherits: vCPUs, virtio workers, io-wq workers, CH's pty child.
Knobs are the raw cgroup encodings — cocoon validates bounds and kernel constraints but does no unit translation; values are policy and stay with the caller:
knob
cgroup file
encoding and constraints
cpu_weight
cpu.weight
1..10000; proportional work-conserving share under contention, not an absolute floor
cpu_quota_us / cpu_period_us
cpu.max
hard ceiling; period defaults to 100000
cpu_burst_us
cpu.max.burst
accumulated runtime credit in us; kernel requires burst <= quota and a finite quota, so peak within one period is bounded by 2x quota
Defaults: --cpu N is the limit (K8s Guaranteed at N)
Every VM enters a scope unconditionally. For a VM created with --cpu N the scope defaults are:
knob
default
semantics
K8s analog
cpu_quota_us
N × period
hard cap at the vCPU count — --cpu finally means what it says
limit
cpu_weight
N
contention share proportional to size (today an N-vCPU VM's N busy threads earn it ~N shares, so this preserves current inter-VM proportions; a flat equal weight would silently change them). The parent contains only VM scopes, so only sibling ratios matter; weight = N stays within the kernel's 1..10000 for any real vCPU count, where 100 × N would overflow past 100 vCPUs
request (= limit → Guaranteed)
cpu_burst_us
0
no spikes past N unless explicitly granted
—
Callers override any of the three raw knobs: weight lowered below the quota share → Burstable overcommit; burst raised → spikes past N as an explicit policy choice; quota raised → headroom for VMM overhead (Kata's pod-overhead concept expressed through the existing knob — no fourth knob).
Documented cost of the default cap: the VMM's I/O service (virtio queues, io-wq) shares the guest's N-core budget. Two visible effects at defaults, quantified by the acceptance A/B below: a saturated VM doing I/O loses the virtio share out of its N cores (baseline floor case: ~13% of a 1-vCPU budget), and virtio threads throttled at period boundaries can add I/O completion latency up to ~one period worst case.
The bounded-average shape (low quota + burst; long-run average enforced at quota, spikes spend quota+burst per period with the credit cap bounding burst duration) remains available for strict metering / abuse containment — never a default.
Scope lifecycle (idempotent, converge-friendly)
Termination paths include stop/delete, hibernate, restore's kill/relaunch, failed launch/configure, and unexpected VMM exit. The contract:
Deterministic path per VM; create-or-reconfigure before launch (create is idempotent). Start never waits for scope emptiness — a relaunch joins the same scope even if a dying predecessor still occupies it; only removal requires empty.
Remove only after the VMM is known dead and the scope is empty; ENOENT counts as success.
A populated or temporarily unremovable scope is left for the next start/stop/converge/GC pass — never kill remaining tasks; VMM liveness checks and the per-VM ops lock stay authoritative.
GC scans vm-*.scope children of the configured parent and removes empty, unowned scopes (bounded residue: an empty dir). The parent is shared by both backends, so unowned means absent from the union of the CH and FC GC snapshots (the existing cross-module GC snapshot helpers provide this), and destructive cleanup of an owned-but-stale scope runs under the owning VM's ops lock. No new on-disk artifacts: knob values live in the existing VM meta record; the scope path derives from the VM ID.
The force-stop path may use cgroup.kill (5.14+) to atomically kill everything in the owned scope — catches the pty child and any stray fork; GC of unowned scopes still never kills.
Scopes are always on, so the preflight is unconditional — there is no reduced no-knob mode. At start, missing cgroup v2, a missing cpu controller, or a missing interface file required by the VM's effective knobs (cpu.max.burst needs kernel >= 5.14 + CONFIG_CFS_BANDWIDTH; the default shape needs only cpu.weight + cpu.max) is an actionable start error naming the missing file — never a silent fallback.
Parent provisioning is one idempotent contract: cocoon creates the configured parent (default cocoon.slice) if absent and enables +cpu in cgroup.subtree_control along the path it controls, then verifies the end state. The parent holds only VM scopes, so the no-internal-process rule is never violated. An admin who points the knob at a custom parent owns its placement; the same end-state verification applies.
Observability: expose cpu.stat (nr_throttled, throttled_usec) through inspect/debug. Durable metering is out of scope for this issue.
Explicit non-goals
No cpuset/vCPU pinning — non-work-conserving, wastes cores; disk queue_affinity is orthogonal and unchanged.
No memory.max / io.max here. The scope is the natural home for them later, but they are separate issues.
No FC jailer integration; the scope covers whatever VMM process we spawn.
No CPU-unit-to-weight translation in cocoon; callers that want K8s-style requests do the mapping themselves.
Requirements and caveats
cgroup v2 unified hierarchy; verify the rt24 custom kernel has CONFIG_CFS_BANDWIDTH and >= 5.14 burst support.
Windows guests: the default cap (quota = N) only bites under real saturation (idle vCPUs hlt properly), so the risk profile is much milder than bounded-average. Hardware validation of clock stability / DPC watchdog under bounded-average settings happens when a consumer actually ships that shape.
cpu.max throttles at period boundaries (visible ~100ms stalls for interactive load at the default period); burst is the intended absorber, smaller periods the fallback.
Acceptance
Baseline: 1-vCPU VM whole-process consumption > 1 host core — done, measured above (100.0% vCPU-only vs 111–113.5% with I/O).
Default cap: the baseline workload on a --cpu 1 VM is capped at 100%; cpu.stat shows throttling.
Tax A/B: same saturated CPU+I/O workload, default cap vs quota raised to N+headroom — record guest throughput delta (expect ~13% floor case) and guest disk latency p99 (expect period-boundary spikes when capped). These numbers are the caller's basis for raising quota.
Weight discrimination: two VMs with weights 1:3 under a parent cpu.max below their combined demand settle at ~1:3 observed consumption; in a second uncontended phase each reaches its own quota.
Burst: bounded-average VM shows spike-then-throttle at the credit boundary in cpu.stat.
VM start latency A/B shows no material regression against the reference host's recorded numbers (scope creation, control-file writes, and clone3 are cheap but not zero); record the delta explicitly.
Preflight: on a host without cgroup v2 (or where the parent cannot be provisioned), vm run fails with an actionable error naming the missing capability.
kill -9 of the VMM leaves only an empty scope dir and converge removes it; stop/delete removes the scope inline; hibernate/restore relaunch reuses the deterministic path.
Darwin build stays green (Linux-only spawn helper).
Problem
The only CPU control cocoon applies today is the vCPU count: CH gets
--cpus boot=N,max=<host cores>(hypervisor/cloudhypervisor/args.go) and nothing constrains the VMM process on the host side. vCPU count bounds guest-code parallelism, not host consumption:halt_poll_ns, default 200us) burns host CPU in vCPU threads even while the guest idles.Neither backend can do this natively. CH
--cpusoffers boot/max/topology/affinity only — upstream expects the embedder to use cgroups (libvirt/Kata do exactly that). FC has cgroup support only via the jailer, which we do not run (#83), and FC is headed to maintenance tier (#161). The mechanism therefore belongs in cocoon, backend-agnostic, at the shared VMM spawn path.Baseline evidence (measured)
Bare-metal 16-core testbed, cocoon master-07f1d69, CH v54.0.0. One VM, 1 vCPU / 2 GB / no NIC / COW disk
direct=on,num_queues=1; whole-process %CPU viapidstat -t, 15–20 s averages:The >1-core claim is real and measured — and this is the floor case: one vCPU means one virtio queue per disk (
num_queues=cpuCount) and a single guest thread issuing I/O. Queue threads scale with vCPU count and net-attached VMs add net queue processing on top, so the absolute overhead grows with exactly the workloads we deploy (multi-vCPU Windows agents doing real network I/O).Proposal: per-VM cgroup v2 scope, raw knobs
At start, create a scope at a deterministic path under a configurable parent (default
<cgroup2 root>/cocoon.slice/vm-<id>.scope) and spawn the VMM directly into it via clone3CLONE_INTO_CGROUP(SysProcAttr.UseCgroupFD/CgroupFD, behind a Linux-only helper so the Darwin build stays valid) — no post-fork attach race, zero cost on the claim path. Every VMM thread inherits: vCPUs, virtio workers, io-wq workers, CH's pty child.Knobs are the raw cgroup encodings — cocoon validates bounds and kernel constraints but does no unit translation; values are policy and stay with the caller:
cpu_weightcpu.weightcpu_quota_us/cpu_period_uscpu.maxcpu_burst_uscpu.max.burstDefaults:
--cpu Nis the limit (K8s Guaranteed at N)Every VM enters a scope unconditionally. For a VM created with
--cpu Nthe scope defaults are:cpu_quota_us--cpufinally means what it sayscpu_weightweight = Nstays within the kernel's 1..10000 for any real vCPU count, where100 × Nwould overflow past 100 vCPUscpu_burst_usCallers override any of the three raw knobs: weight lowered below the quota share → Burstable overcommit; burst raised → spikes past N as an explicit policy choice; quota raised → headroom for VMM overhead (Kata's pod-overhead concept expressed through the existing knob — no fourth knob).
Documented cost of the default cap: the VMM's I/O service (virtio queues, io-wq) shares the guest's N-core budget. Two visible effects at defaults, quantified by the acceptance A/B below: a saturated VM doing I/O loses the virtio share out of its N cores (baseline floor case: ~13% of a 1-vCPU budget), and virtio threads throttled at period boundaries can add I/O completion latency up to ~one period worst case.
The bounded-average shape (low quota + burst; long-run average enforced at quota, spikes spend quota+burst per period with the credit cap bounding burst duration) remains available for strict metering / abuse containment — never a default.
Scope lifecycle (idempotent, converge-friendly)
Termination paths include stop/delete, hibernate, restore's kill/relaunch, failed launch/configure, and unexpected VMM exit. The contract:
ENOENTcounts as success.vm-*.scopechildren of the configured parent and removes empty, unowned scopes (bounded residue: an empty dir). The parent is shared by both backends, so unowned means absent from the union of the CH and FC GC snapshots (the existing cross-module GC snapshot helpers provide this), and destructive cleanup of an owned-but-stale scope runs under the owning VM's ops lock. No new on-disk artifacts: knob values live in the existing VM meta record; the scope path derives from the VM ID.cgroup.kill(5.14+) to atomically kill everything in the owned scope — catches the pty child and any stray fork; GC of unowned scopes still never kills.Platform preflight and parent provisioning
Scopes are always on, so the preflight is unconditional — there is no reduced no-knob mode. At start, missing cgroup v2, a missing
cpucontroller, or a missing interface file required by the VM's effective knobs (cpu.max.burstneeds kernel >= 5.14 +CONFIG_CFS_BANDWIDTH; the default shape needs onlycpu.weight+cpu.max) is an actionable start error naming the missing file — never a silent fallback.Parent provisioning is one idempotent contract: cocoon creates the configured parent (default
cocoon.slice) if absent and enables+cpuincgroup.subtree_controlalong the path it controls, then verifies the end state. The parent holds only VM scopes, so the no-internal-process rule is never violated. An admin who points the knob at a custom parent owns its placement; the same end-state verification applies.Observability: expose
cpu.stat(nr_throttled,throttled_usec) through inspect/debug. Durable metering is out of scope for this issue.Explicit non-goals
queue_affinityis orthogonal and unchanged.memory.max/io.maxhere. The scope is the natural home for them later, but they are separate issues.Requirements and caveats
CONFIG_CFS_BANDWIDTHand >= 5.14 burst support.cpu.maxthrottles at period boundaries (visible ~100ms stalls for interactive load at the default period); burst is the intended absorber, smaller periods the fallback.Acceptance
Baseline: 1-vCPU VM whole-process consumption > 1 host core— done, measured above (100.0% vCPU-only vs 111–113.5% with I/O).--cpu 1VM is capped at 100%;cpu.statshows throttling.cpu.maxbelow their combined demand settle at ~1:3 observed consumption; in a second uncontended phase each reaches its own quota.cpu.stat.clone3are cheap but not zero); record the delta explicitly.vm runfails with an actionable error naming the missing capability.