Skip to content

sched: env-tunable idle spin-before-halt (OSV_IDLE_SPIN) - #1501

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

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

Conversation

@gburd

@gburd gburd commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

sched: env-tunable idle spin-before-halt (OSV_IDLE_SPIN)

An idle CPU with no runnable thread spins polling incoming_wakeups a fixed
10000 iterations before halting via arch::wait_for_interrupt, which under a
hypervisor is a VM-exit. For a request/reply server that runs one blocking
worker per connection, each worker is typically re-woken by its next request a
short time after it blocks. When the spin is too short to catch that wake, the
halt and the subsequent wake-IPI cost a round-trip of several VM-exits per
request. At high connection counts this halt/wake round-trip becomes the
throughput ceiling: the guest saturates its VM-exit rate while most exits are
halts and their wakes, and the CPUs are otherwise idle.

This makes the spin-before-halt count tunable via the OSV_IDLE_SPIN
environment variable so a busy server can spin slightly longer before halting
an idle CPU and thereby avoid the halt/wake round-trip per request.

Behavior

  • Default unchanged. With OSV_IDLE_SPIN unset the count is 10000, so the
    idle path is byte-for-byte the historical behavior. A zero or unparseable
    value also falls back to 10000.
  • Opt-in. A workload that benefits from trading idle CPU cycles for fewer
    halt/wake round-trips raises it (for example OSV_IDLE_SPIN=100000). A longer
    spin burns cycles on a genuinely idle system, which is why it is opt-in rather
    than a new default.

Effect

This is an efficiency and tunability change, not a throughput-uplift claim. The
mechanism is real and was measured directly; the end-to-end throughput payoff is
host-conditional, so the honest statement is below rather than a headline number.

Measured mechanism. On a qemu 8.1.3 KVM host (smp16, one blocking worker per
connection), raising the spin from 10000 to 100000 collapsed guest halt VM-exits at
32 connections:

halt_exits/s   48975  ->  434     (99% reduction)

So the guest-side spin genuinely catches an imminent wake and skips the halt.

But it is redundant with host-side halt-polling, and then it does nothing. On a
later qemu with a wider adaptive halt_poll_ns window, the same A/B (10000 ->
200000 -> 5000000) produced no change at all:

halt_exits/s   57692  ->  58084   (unchanged)
throughput                        (unchanged across all three values)

with halt_poll_ok already accounting for roughly 46k of ~58k halts. KVM was
already catching those wakes, so a longer guest spin had nothing left to collapse
and only burned guest cycles.

Both runs are internally consistent: throughput moved if and only if halts
collapsed. Two mechanisms race to catch the imminent request, the guest's idle spin
and the host's KVM halt-poll, and whichever fires first wins.

Therefore: this knob reduces per-request halt VM-exits on hosts whose hypervisor
halt-polling does not already catch those wakes, and is a no-op (pure CPU burn) on
hosts where it does. Where it does fire, the throughput effect was modest and
concentrated in write-heavy and deep-tail cases, where workers block longest. It is
not a uniform win, and I am not claiming one. An earlier revision of this change
(#1476) asserted that it "collapses the per-request halt/wake round-trip" with
"no low-concurrency regression" and implied a general throughput gain; that was
unsupported by a single stable number and is corrected here and in the commit
message.

Retaining it as an opt-in tunable rather than a default follows the existing
precedent for this class of knob: the useful setting is workload and
host-dependent, so the decision belongs to the operator.

Usage constraint

The value is read once with getenv and cached in a function-local static, and
the first read happens on an idle thread started by smp_launch(). So it has to
be in the environment before that point:

  • --env=OSV_IDLE_SPIN=N works: loader options are parsed in parse_options()
    (loader.cc), before smp_launch().
  • A leading OSV_IDLE_SPIN=N on the application command line does not: the
    command line is parsed later, on the main application thread, after the idle
    threads have already started and latched the value.

Worth stating because the ineffective form fails silently.

Supersedes #1476

This replaces #1476, which carried the same code with an outdated commit
message
. #1476's description had been corrected to the honest framing above,
but its commit message still asserted that the knob "lifts throughput at high
concurrency" and that the halt/wake round-trip "becomes the throughput ceiling",
with no mention of the host-conditionality that makes it a no-op on hosts whose
hypervisor already halt-polls those wakes. A description is editable forever; a
commit message is what gets merged into git log permanently, so a retraction
that lands only in the description does not survive the merge.

A PR's head branch is not editable on GitHub (PATCH /pulls/{n} accepts a
head field and silently discards it), so this could not be repointed in place
and is a fresh PR instead.

No code changed. The tree is byte-identical to #1476's head:

$ git rev-parse c6d1c2156^{tree} 296b9ec66^{tree}
8c1e06ee8bc6c726c9e8bff72758cc4f68c14b08
8c1e06ee8bc6c726c9e8bff72758cc4f68c14b08
$ git diff c6d1c2156 296b9ec66      # empty

Scope

One file (core/sched.cc), +24/-2. Generic base scheduler code; does not depend
on any other change and is independently reviewable. Applies directly on
master.

An idle CPU with no runnable thread spins polling incoming_wakeups a fixed
10000 iterations before halting via arch::wait_for_interrupt, which under
a hypervisor is a VM-exit. For a request/reply server that runs one
blocking worker per connection, each worker is typically re-woken by its
next request a short time after it blocks. When the spin is too short to
catch that wake, the halt and the subsequent wake-IPI cost a round-trip of
several VM-exits per request.

Make the spin-before-halt count tunable via the OSV_IDLE_SPIN environment
variable. The default is unchanged (10000), so an unset variable preserves
the historical behavior byte-for-byte, and a zero or unparseable value
also falls back to 10000. A workload that benefits from trading idle CPU
cycles for fewer halt/wake round-trips raises it; a longer spin burns
cycles on a genuinely idle system, which is why it is opt-in rather than a
new default.

This is an efficiency and tunability change, not a throughput-uplift
claim. The mechanism is real and was measured directly, but its end-to-end
effect is host-conditional, because the guest's idle spin competes with
the host hypervisor's own halt-polling and whichever catches the imminent
wake first wins.

On a qemu 8.1.3 KVM host (smp16, one blocking worker per connection),
raising the spin from 10000 to 100000 collapsed guest halt VM-exits at 32
connections from 48975/s to 434/s, a 99% reduction: the guest-side spin
genuinely caught an imminent wake and skipped the halt.

On a later qemu with a wider adaptive halt_poll_ns window, the same A/B
(10000 -> 200000 -> 5000000) produced no change at all: halt VM-exits went
57692/s -> 58084/s, i.e. unchanged, with halt_poll_ok already accounting
for roughly 46k of about 58k halts. KVM was already catching those wakes,
so a longer guest spin had nothing left to collapse and only burned guest
cycles.

Both runs are internally consistent: throughput moved if and only if halts
collapsed. So this knob reduces per-request halt VM-exits on hosts whose
hypervisor halt-polling does not already catch those wakes, and is a no-op
(pure CPU burn) on hosts where it does. Where it does fire, the throughput
effect was modest and concentrated in write-heavy and deep-tail cases,
where workers block longest. It is not a uniform win and none is claimed.

One usage constraint follows from where the value is read. It is fetched
once with getenv and cached in a function-local static, and the first read
happens on the idle thread that smp_launch() starts. So the variable has
to be present in the environment before then: passing it as a loader
option (--env=OSV_IDLE_SPIN=N, applied while the loader parses its
options) works, whereas setting it as a leading KEY=value on the
application command line does not, because the command line is parsed
later on the main application thread, after the idle threads have already
started and latched the value.
@gburd

gburd commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Supersedes #1476 (now closed).

#1476 carried this exact code, but its commit message still asserted that the knob "lifts throughput at high concurrency" and that the halt/wake round-trip "becomes the throughput ceiling", with no mention of the host-conditionality that is the actual result. Its description had been corrected; the commit message had not, and the commit message is the part that gets merged permanently.

Its head branch could not be repointed here because a PR's head is not editable on GitHub (PATCH /pulls/{n} accepts a head field and silently discards it), so this is a fresh PR rather than an update in place.

The tree is byte-identical to #1476's head (8c1e06ee8bc6c726c9e8bff72758cc4f68c14b08 for both), and git diff c6d1c2156 296b9ec66 is empty, so no code changed and there is nothing new to review in core/sched.cc. Draft, matching #1476's state.

@gburd

gburd commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #1511, which replaces this fixed idle-spin count with an adaptive per-CPU window. Measurement showed a fixed count is the wrong control (host-conditional, and either too short or wasteful); the adaptive window subsumes the fixed behavior (OSV_IDLE_SPIN_ADAPTIVE=0) and wins on both the busy workload (+65% op/s, 4x fewer halt VM-exits) and the idle axis (~1/3 the idle CPU). Closing in favor of #1511.

@gburd gburd closed this Sep 19, 2026
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