Skip to content

sched: adaptive per-CPU idle-spin window (guest-side halt-polling) - #1511

Open
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/sched-adaptive-idle-spin-master
Open

gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/sched-adaptive-idle-spin-master

Conversation

@gburd

@gburd gburd commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

sched: adaptive per-CPU idle-spin window (guest-side halt-polling)

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 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:

  • 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, above zero 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, adaptive vs the old fixed 10000:

metric fixed 10000 adaptive change
op/s 27200 44841 +65%
wake-to-entry latency 137us 78us -43%
p99 3484us 2118us -39%
guest halt VM-exits/s 41913 11039 4x fewer

Genuinely-idle guest, host CPU counter (qemu utime+stime over a fixed window):

arm CPU of one core
adaptive ~20.5%
fixed 10000 ~63.5%

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=0 pins the window at the cap, i.e. the old fixed-count behavior at whatever OSV_IDLE_SPIN or 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 is OSV_IDLE_SPIN_ADAPTIVE=0) and wins on both the busy and the idle axis. #1501 is now closed as superseded by this.

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.
gburd added a commit to gburd/osv-1 that referenced this pull request Sep 21, 2026
…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.
@gburd

gburd commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Scope qualification: no measurable effect when running natively on the hardware

The 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:

concurrency delta rep spread
32 -2.96% 1.01-1.05x
64 +2.63% 1.01-1.05x

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 (adaptive=1 cap=100000 versus adaptive=0 cap=10000) - the PR as posted had no such proof line, so I added one for the test; an A/B whose arms did not really differ is worse than no A/B.

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, arch::wait_for_interrupt is an ordinary HLT on real hardware, so there is no exit to avoid and nothing to win. It also fits the earlier observation that a fixed very large spin count was null under QEMU when the host had halt_poll_ns enabled: the host was already catching those halts itself.

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 OSV_IDLE_SPIN) and decays to the floor when genuinely idle, so it should not cost anything where it does not help. But it is not a general win, and it does nothing on bare hardware.

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.

gburd added a commit to gburd/osv-1 that referenced this pull request Sep 21, 2026
…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.
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.

1 participant