Skip to content

sched: disperse threads faster in the load balancer - #1464

Open
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/sched-load-balance
Open

sched: disperse threads faster in the load balancer#1464
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/sched-load-balance

Conversation

@gburd

@gburd gburd commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What

cpu::load_balance() is the only mechanism in the scheduler that spreads threads across CPUs: thread::start() places a new thread on its creator's CPU, and thread::wake() re-wakes a blocked thread on the CPU it last ran on. Neither disperses work on its own. The balancer runs once every 100ms and migrates a single thread per pass.

That is far too slow for a workload that fans many threads out from one parent (a server that forks or spawns a worker per connection). The workers pile onto the few CPUs they were started or woken on while the rest of the machine sits idle, and a one-thread-per-100ms drip cannot catch up under a high request rate.

This changes the balancer to run every 10ms and, on each pass, keep migrating the most-migratable queued thread to the least-loaded CPU until this CPU is no longer meaningfully more loaded, draining the whole imbalance in one pass. Work per pass is bounded by the CPU count so a transient spike cannot become a migration storm. It reuses the existing, proven migration path unchanged.

Why (measured)

A many-connection server benchmark on a 16-vCPU guest, driven over the network, was collapsing past 8 connections. Profiling showed the guest was ~93% idle at 32 connections while throughput fell: the sampler put 93.5% of CPU time in cpu::idle(), thread dumps showed ~37 worker threads crammed onto 4 of 16 vCPUs with 12 idle, and the host saw the VM using about 6 of 16 cores. The bottleneck was thread placement, not lock contention, CPU saturation, or I/O.

Before / after (same guest, fresh boot each, connections = c)

c before tps after tps delta
8 68543 64294 -6%
16 28113 31892 +13%
32 17015 26075 +53%
48 14341 24692 +72%
64 13139 22698 +73%

It trades ~6% at low concurrency (extra migration traffic) for the tail no longer collapsing. Measured effect on placement: idle time 93.5% -> 89.7%, worker spread 4 -> 6 CPUs.

Scope / honest note

General scheduler change, no application specifics. It does not fully saturate the machine: even after the fix the guest is still substantially idle at high concurrency, because the remaining limit is per-request round-trip latency through the single network receive path and per-packet wakeup IPIs, which grows with concurrency. That is a separate, larger change (batching wakeups) and is not attempted here; this PR removes the placement collapse, which is the dominant effect.

The per-CPU load balancer was the only mechanism that spreads runnable
threads across CPUs: thread::start() places a new thread on its creator's
CPU and thread::wake() re-wakes a blocked thread on the CPU it last ran on,
so neither disperses work on its own. Running load_balance() once every
100ms and migrating a single thread per wakeup is far too slow for a
workload that fans many threads out from one parent -- for example a server
that forks or spawns a worker per connection. The workers pile onto the few
CPUs they were started or woken on while the rest of the machine sits idle,
and a one-thread-per-100ms drip cannot catch up under a high request rate.

Measured on a 16-vCPU guest running a thread-per-connection server driven
over the network: as client concurrency rose past the point where workers
should have spread out, CPU utilization stayed low (the machine was ~90%
idle) yet throughput regressed, because requests queued behind workers stuck
on a handful of CPUs. A sampling profile confirmed the CPUs were idle, not
contended, and a thread dump showed dozens of workers concentrated on a few
CPUs with the majority idle.

Run the balancer more often (10ms) and, on each pass, keep migrating the
most-migratable queued thread to the least-loaded CPU until this CPU is no
longer meaningfully more loaded -- draining the whole imbalance in one pass
instead of one thread of it. The number of migrations per pass is bounded by
the CPU count so a transient spike cannot become an unbounded migration
storm. The migration mechanics are unchanged.

On the workload above this flattened the throughput regression: at high
concurrency throughput improved by 50-70% and no longer collapsed, at a
small (single-digit percent) cost at low concurrency from the extra
migration traffic. Only the thread placement path is affected; the runqueue
and runtime-accounting logic are untouched.

Signed-off-by: Greg Burd <greg@burd.me>
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