Conversation
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.
|
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 ( The tree is byte-identical to #1476's head ( |
|
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. |
sched: env-tunable idle spin-before-halt (
OSV_IDLE_SPIN)An idle CPU with no runnable thread spins polling
incoming_wakeupsa fixed10000 iterations before halting via
arch::wait_for_interrupt, which under ahypervisor 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_SPINenvironment 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
OSV_IDLE_SPINunset the count is 10000, so theidle path is byte-for-byte the historical behavior. A zero or unparseable
value also falls back to 10000.
halt/wake round-trips raises it (for example
OSV_IDLE_SPIN=100000). A longerspin 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:
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_nswindow, the same A/B (10000 ->200000 -> 5000000) produced no change at all:
with
halt_poll_okalready accounting for roughly 46k of ~58k halts. KVM wasalready 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
getenvand cached in a function-local static, andthe first read happens on an idle thread started by
smp_launch(). So it has tobe in the environment before that point:
--env=OSV_IDLE_SPIN=Nworks: loader options are parsed inparse_options()(
loader.cc), beforesmp_launch().OSV_IDLE_SPIN=Non the application command line does not: thecommand 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 logpermanently, so a retractionthat lands only in the description does not survive the merge.
A PR's head branch is not editable on GitHub (
PATCH /pulls/{n}accepts aheadfield and silently discards it), so this could not be repointed in placeand is a fresh PR instead.
No code changed. The tree is byte-identical to #1476's head:
Scope
One file (
core/sched.cc), +24/-2. Generic base scheduler code; does not dependon any other change and is independently reviewable. Applies directly on
master.