Conversation
An idle CPU with no runnable thread spins polling incoming_wakeups a fixed
number of iterations before halting via arch::wait_for_interrupt, which under a
hypervisor is a VM-exit. For a request/reply server (one blocking worker per
connection) each worker is typically re-woken by its next request a short time
after it blocks; if the spin catches that wake, the halt and the wake-IPI it
forces (several VM-exits per request) are avoided.
A fixed spin count is the wrong control. Too short and a to-be-rewoken thread's
CPU halts just before its wake, forcing the expensive IPI/VM-exit; too long and
it burns cycles when genuinely idle (which is why the fixed count had to be
opt-in). This adapts the spin window per CPU from recent history, like Linux's
adaptive KVM halt-polling (halt_poll_ns) but on the guest side, so it helps even
where the host hypervisor does not halt-poll.
The asymmetry is the whole point, and it was measured: a symmetric grow/shrink
with a low floor loses badly to the fixed count on a busy workload, because a
CPU that halts occasionally decays its window and then spends less time polled
than the fixed count, taking more wake IPIs, not fewer. So:
- Caught a wake mid-spin: the CPU is wake-heavy. Jump the window straight to
the cap so it stays fully polled and keeps suppressing wake IPIs. A busy CPU
snaps back to the cap the instant it catches one wake.
- Halted without catching a wake: a single halt on a busy CPU is normal, so do
not collapse the window. Only after several consecutive genuine halts does
the CPU look truly idle; then halve the window toward a floor so it stops
burning cycles and the host can reclaim the vCPU. Any caught wake resets the
streak.
Bounded by a floor (256, >0 so the idle_poll handshake still gets a chance to
suppress a wake IPI) and a cap (OSV_IDLE_SPIN, default 100000). A wake-heavy CPU
grows to the cap and stays there; a genuinely idle CPU decays to the floor and
halts.
Measured, qemu KVM host, host halt_poll_ns=200000:
- Busy 32-writer durable-write isolate: op/s 27200 -> 44841 (+65%), wake-to-
entry latency 137us -> 78us, p99 3484us -> 2118us, guest halt VM-exits/s
41913 -> 11039 (4x fewer), vs the fixed 10000.
- Genuinely-idle guest (host counter, qemu utime+stime): adaptive consumes
~20.5% of one core vs the fixed count's ~63.5% (about a third), so the high
cap costs nothing when idle.
This is a general scheduler change (any cross-vCPU-wakeup-heavy workload
benefits); it is not throughput-parity for any specific workload and does not
claim to be. OSV_IDLE_SPIN_ADAPTIVE=0 pins the window at the cap, i.e. the old
fixed-count behavior at whatever OSV_IDLE_SPIN or the default is.
…nd OSV_FP_ROWS The cloudius-systems#1511 adaptive idle-spin lever had NO console proof line, so an A/B of it could not satisfy the standing rule (a lever's RESOLVED value must be printed and verified per arm; never infer binding from the cmdline). Add one printf from the first do_idle -- after parse_options, so --env is visible. Separately: EC2's serial console is a 64 KiB RING and the footprint probe's per-AS FPROW rows (~11 lines each, 420 lines per window observed) push the boot header out of it, destroying the very lever proof they sit beside. Keep the FPTOT summary (which carries per_as_KB) unconditional; gate the FPROW detail on OSV_FP_ROWS=1.
Scope qualification: no measurable effect when running natively on the hardwareThe table above is labelled as a qemu/KVM measurement and that labelling is load-bearing, so I want to make the limit explicit rather than leave it implied. I re-tested this change with OSv booted natively on an EC2 instance (no QEMU, no Firecracker, no KVM: OSv owns the MBR, the ENA NIC, the local NVMe and all 32 vCPUs; the Nitro layer remains beneath the instance type). On that substrate the change is a null:
The result is inside run-to-run spread and it flips sign, so there is no effect to claim either way. The two arms were proven to actually differ, printed per arm and read off the console ( The mechanism explanation is consistent with this. The win comes from avoiding a halt that costs a VM-exit plus the wake IPI that follows it. Running natively, So the honest scope of this PR is: a guest-side optimisation for hypervisors that do not halt-poll on the host side. That is still a real class of deployment, and the change is bounded (floor 256, cap One further limit I should state: the native re-test was a read-only workload. The +65% in the table came from a 32-writer durable-write isolate, which I have not yet re-run natively. Until I do, the right reading is "null on native read-only", not "null on native". I will re-run that isolate and report it here. If reviewers would rather see this PR wait for that number, I am happy to hold it. |
…nd OSV_FP_ROWS The cloudius-systems#1511 adaptive idle-spin lever had NO console proof line, so an A/B of it could not satisfy the standing rule (a lever's RESOLVED value must be printed and verified per arm; never infer binding from the cmdline). Add one printf from the first do_idle -- after parse_options, so --env is visible. Separately: EC2's serial console is a 64 KiB RING and the footprint probe's per-AS FPROW rows (~11 lines each, 420 lines per window observed) push the boot header out of it, destroying the very lever proof they sit beside. Keep the FPTOT summary (which carries per_as_KB) unconditional; gate the FPROW detail on OSV_FP_ROWS=1.
sched: adaptive per-CPU idle-spin window (guest-side halt-polling)
An idle CPU with no runnable thread spins polling
incoming_wakeupsa fixed number of iterations before halting viaarch::wait_for_interrupt, which under a hypervisor is a VM-exit. For a request/reply server (one blocking worker per connection) each worker is typically re-woken by its next request a short time after it blocks; if the spin catches that wake, the halt and the wake-IPI it forces (several VM-exits per request) are avoided.A fixed spin count is the wrong control. Too short and a to-be-rewoken thread's CPU halts just before its wake, forcing the expensive IPI/VM-exit; too long and it burns cycles when genuinely idle (which is why a fixed count has to be conservative). This adapts the spin window per CPU from recent history, like Linux's adaptive KVM halt-polling (
halt_poll_ns) but on the guest side, so it helps even where the host hypervisor does not halt-poll.The asymmetry is the point (and was measured)
A symmetric grow/shrink with a low floor loses badly to a fixed count on a busy workload, because a CPU that halts occasionally decays its window and then spends less time polled than the fixed count, taking more wake IPIs, not fewer. So the window moves asymmetrically:
Bounded by a floor (256, above zero so the
idle_pollhandshake still gets a chance to suppress a wake IPI) and a cap (OSV_IDLE_SPIN, default 100000). A wake-heavy CPU grows to the cap and stays there; a genuinely idle CPU decays to the floor and halts.Measured (qemu KVM host, host halt_poll_ns=200000)
Busy 32-writer durable-write isolate, adaptive vs the old fixed 10000:
Genuinely-idle guest, host CPU counter (qemu utime+stime over a fixed window):
So adaptive is a large win on a wake-heavy workload and consumes about a third of the idle CPU of the fixed count when the guest is idle, i.e. the high cap costs nothing when there is no work.
Scope and honesty
This is a general base-scheduler change (
core/sched.cc,include/osv/sched.hh); any cross-vCPU-wakeup-heavy workload benefits. It is an efficiency/latency change, not a throughput-parity claim for any particular workload, and it does not claim to be one.OSV_IDLE_SPIN_ADAPTIVE=0pins the window at the cap, i.e. the old fixed-count behavior at whateverOSV_IDLE_SPINor the default is, so the previous behavior is one env var away.Applies directly on master; one commit, two files.
Supersedes #1501
This replaces #1501 (
sched: env-tunable idle spin-before-halt), which added the same knob as a fixed count. Measurement showed the fixed count is the wrong control (a fixed spin is host-conditional and either too short or wasteful); the adaptive window subsumes it (the fixed behavior isOSV_IDLE_SPIN_ADAPTIVE=0) and wins on both the busy and the idle axis. #1501 is now closed as superseded by this.