Cheaper coordination among workers: poll before parking, fewer signals, serialize broadcasts once - #816
Cheaper coordination among workers: poll before parking, fewer signals, serialize broadcasts once#816frankmcsherry wants to merge 5 commits into
Conversation
Parking a thread and waking it again costs one to four microseconds on common platforms, and a worker with nothing to do parked immediately. For tightly coupled workers, such as a barrier per loop iteration, that wake latency was nearly the whole per-iteration cost. A worker now polls its channels for a bounded time before parking, set by `WorkerConfig::idle_spin` (default 10 microseconds, `--idle-spin` on the command line). Polling occupies a core, so the duration bounds the CPU an idle worker burns each time it goes idle; zero restores the previous behavior. The event-surfacing prologue of `step_or_park` is factored into `poll_events` so the polling loop can reuse it. On an M4 with four workers, examples/barrier.rs went from 2.5 to 1.3 microseconds per iteration, and from 3.1 to 0.8 with two. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T
The counting pushers announced every push, including the `None` that flushes a channel, although the wrapped pushers are unbuffered and a `None` enqueues nothing. For the cross-thread pusher this cost an events message and an unpark per peer per flush, and `Progcaster::send` flushes after every broadcast, so each progress update woke every peer twice. Receivers then sorted and deduplicated twice the events. With workers polling before they park this was about 18% of a four-worker barrier iteration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T
The intra-process allocator gave a worker an mpsc pusher to itself, so a message to self cost two cross-thread sends, a self-unpark, and two cross-thread receives. The self pusher is now a thread-local channel, and `LocalFirst` drains it ahead of the shared receiver. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T
`TcpAllocator::broadcast` already serialized a message once per remote process and let the receiving process share the bytes among its workers, but the intra-process allocator inherited the default `broadcast`, which serialized once per local peer. `ProcessAllocator` now implements `broadcast`: one pusher serializes into a staging buffer whose target, `Fanout`, hands each other worker a clone of the `Bytes` handle through that worker's own send endpoint, via the new `SendEndpoint::push_bytes`. Going through the destination's endpoint keeps per-destination ordering and runs its spill policy as before. The worker's own copy stays typed, through a thread-local queue, which is acceptable for progress messages as they are small and never worth paging out; data channels keep the shared byte queue for self-sends. With four workers and `--zerocopy`, examples/barrier.rs went from 1.57 to 1.34 microseconds per iteration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T
`Bincode::from_bytes` re-serialized the payload it had just deserialized to check its length, a full traversal of every received message, including data containers. The check is kept under `debug_assertions`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T
|
Independent reproduction on x86-64 Linux, 32 logical CPUs, comparing Typed allocator:
Zero-copy allocator:
The direction holds everywhere, but the magnitude is smaller than the numbers in the description. A single worker takes 1.89 seconds here against 0.58 on the M4, so this machine is about three times slower per core. Park and unpark cost is a fixed OS charge, so it is a smaller share of the total and removing it buys proportionally less. Speedup peaks near six workers at 1.87x and then settles around 1.75x out to 24 workers without decaying, which is what one would expect if the remaining growth is the O(w²) progress broadcast that this PR does not touch. At 32 workers on 32 CPUs the ratio collapses to 1.21x, but that configuration leaves no core for anything else and measures oversubscription rather than the change. The w=12 entry at 1.58x is two-rep noise, not a real dip. Two points from the description did not reproduce:
🤖 Posted by Claude Code |
|
Full sweep, extending the earlier comment to both allocators and to Typed allocator, with the branch's default 10 µs poll budget and with polling disabled:
Zero-copy allocator:
The two allocators improve for different reasons, and the shapes say so. Typed peaks at 1.87x around six workers and then holds near 1.75x out to 24, which is what a fixed per-worker saving looks like once the O(w²) progress broadcast dominates. Zero-copy starts at 1.12x and climbs monotonically to 1.66x at 24 workers, consistent with the serialize-once broadcast saving growing with the number of local peers. At 32 workers on 32 CPUs both collapse, to 1.21x and 1.13x, but that configuration leaves no core for anything else and measures oversubscription rather than the change. Zero-copy is also faster than typed on both revisions at every worker count here, including one worker at 1.25 against 1.89 seconds. That gap predates this PR. On the
🤖 Posted by Claude Code |
Reduces the cost of fine-grained coordination among workers, using
examples/barrier.rsas the yardstick: a loop whose only work is a progress round per iteration.Where the time went
With two to four workers a barrier iteration took 2.5 to 3.1 microseconds, and almost all of it was the OS: each worker parked once per iteration after sending its progress update, and each wake cost one to four microseconds (macOS, M4). A pure spin barrier across four threads on the same machine is about 100 ns; a park/unpark barrier is about 3 µs. A single worker's step is about 0.6 µs, mostly progress tracking, and is unchanged here.
Two smaller things showed up along the way.
Progcaster::sendflushes after each broadcast, and the counting pushers announced the flush to every peer with an events message and an unpark, so each update woke every peer twice. And a worker's message to itself went through the shared mpsc channels, or on the zero-copy path through a serialization, its own byte queue, a self-unpark, and a deserialization.Changes, one commit each
WorkerConfig::idle_spin, default 10 µs,--idle-spin MICROS. An idle worker polls its channels for at most this long and parks only if nothing arrives; zero restores the old behavior. Callers ofstep()never spin. The prologue ofstep_or_parkis nowpoll_events, so the polling loop reuses it.push(None)inArcPusherand the thread-localPusher: nothing was enqueued, so there is nothing to announce or to wake for.Processallocator (LocalFirst). Those queues were never spillable, so nothing is lost.ProcessAllocatorimplementsbroadcast: one serialization into a staging buffer, and each other worker gets a clone of theByteshandle through its ownSendEndpointvia the newpush_bytes, so per-destination ordering and spill policies are untouched. This is whatTcpAllocator::broadcastalready did per remote process. The worker's own copy stays typed, which is fine for progress messages; data channels keep the shared byte queue for self-sends so they remain pageable.Bincode::from_bytessize re-check is debug-only. It re-serialized every received payload to compare lengths.Numbers
Barrier, 1M iterations, wall seconds on an M4 (four performance cores):
--zerocopy--zerocopyexamples/pingpong.rswith 200k rounds and four workers: 1.5 s to 0.54 s typed, 0.76 s to 0.56 s zero-copy.Even a 1 µs poll budget captures nearly all of the barrier gain; the default of 10 µs is there to cover slightly longer waits elsewhere.
Things worth a reviewer's eye
idle_spinis a policy change for every user: at most 10 µs of a core per idle transition, which is negligible for busy or fully idle workers but is measurable for a worker woken every 100 µs, and it delays workers with real work in oversubscribed deployments.Config::default()is now written out to carry the default.--idle-spin 0theNonechange makes three and four typed workers slower than master, because the redundant wakeups had been acting as an accidental spin. I kept it since it is strictly less work and the default covers it.Configlosesderive(Default); the manual impl is equivalent apart from the new field.Not included, measured and shelved for now:
ChangeBatchbacked byVec(−22% at one worker, but an API change and an allocation per message), a total-order fast path forMutableAntichain, and a sorted worklist in the reachability tracker.🤖 Generated with Claude Code
https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T