Skip to content

Cheaper coordination among workers: poll before parking, fewer signals, serialize broadcasts once - #816

Open
frankmcsherry wants to merge 5 commits into
masterfrom
barrier-sync
Open

Cheaper coordination among workers: poll before parking, fewer signals, serialize broadcasts once#816
frankmcsherry wants to merge 5 commits into
masterfrom
barrier-sync

Conversation

@frankmcsherry

Copy link
Copy Markdown
Member

Reduces the cost of fine-grained coordination among workers, using examples/barrier.rs as 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::send flushes 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

  • Poll before parking. 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 of step() never spin. The prologue of step_or_park is now poll_events, so the polling loop reuses it.
  • No signal on push(None) in ArcPusher and the thread-local Pusher: nothing was enqueued, so there is nothing to announce or to wake for.
  • Self-sends take a thread-local queue in the typed Process allocator (LocalFirst). Those queues were never spillable, so nothing is lost.
  • Zero-copy broadcast serializes once for local peers. ProcessAllocator implements broadcast: one serialization into a staging buffer, and each other worker gets a clone of the Bytes handle through its own SendEndpoint via the new push_bytes, so per-destination ordering and spill policies are untouched. This is what TcpAllocator::broadcast already 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_bytes size 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):

w=1 w=2 w=3 w=4
master, typed 0.58 3.3 2.7 2.6
this branch, typed 0.58 0.80 1.07 1.31
master, --zerocopy 2.15 1.40 1.74
this branch, --zerocopy 0.97 1.24 1.53

examples/pingpong.rs with 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

  • The default idle_spin is 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.
  • With --idle-spin 0 the None change 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.
  • Config loses derive(Default); the manual impl is equivalent apart from the new field.

Not included, measured and shelved for now: ChangeBatch backed by Vec (−22% at one worker, but an API change and an allocation per message), a total-order fast path for MutableAntichain, and a sorted worklist in the reachability tracker.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QDsLC46QQW9aBrksaWad6T

frankmcsherry and others added 5 commits September 3, 2026 21:04
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
@antiguru

antiguru commented Sep 4, 2026

Copy link
Copy Markdown
Member

Independent reproduction on x86-64 Linux, 32 logical CPUs, comparing c2c336c5 (the PR base) against 8783a861. Release profile from the workspace Cargo.toml, so opt-level = 3 and debug-assertions = false. Barrier ran 1M iterations, so wall seconds equal microseconds per iteration; best of 3 for w up to 8 and best of 2 above that.

Typed allocator:

w base this branch speedup
1 1.89 1.77 1.07x
2 2.00 1.66 1.20x
3 2.90 2.04 1.42x
4 4.00 2.49 1.61x
6 6.64 3.55 1.87x
8 11.03 6.15 1.79x
12 19.10 12.09 1.58x
16 28.97 16.55 1.75x
24 43.56 25.02 1.74x
32 74.85 61.67 1.21x

Zero-copy allocator:

w base this branch speedup
2 2.25 2.01 1.12x
3 2.76 2.46 1.12x
4 3.25 2.87 1.13x

examples/pingpong.rs with 200k rounds, one element, four workers: typed 1.82 to 1.21 seconds, zero-copy 1.84 to 1.16 seconds.

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:

  • Single-worker barrier is 6% faster here, 1.89 to 1.77, rather than unchanged. The debug-only Bincode::from_bytes size re-check is the plausible cause, though the margin is close to run-to-run noise.
  • --idle-spin 0 is not slower than master here. Three workers give 2.15 against master's 2.90, and four give 2.71 against 4.00. So on Linux most of the win comes from the other commits, presumably the dropped signal on push(None) and the thread-local self-sends, rather than from polling. The default 10 µs budget only moves four workers from 2.71 to 2.49, and --idle-spin 1 gives 2.53, which matches the claim that a 1 µs budget captures nearly all of the barrier gain.

🤖 Posted by Claude Code

@antiguru

antiguru commented Sep 4, 2026

Copy link
Copy Markdown
Member

Full sweep, extending the earlier comment to both allocators and to --idle-spin 0. Same setup: x86-64 Linux, 32 logical CPUs, c2c336c5 against 8783a861, workspace release profile (opt-level = 3, debug-assertions = false), barrier at 1M iterations so wall seconds equal microseconds per iteration, best of 3 for w up to 8 and best of 2 above that.

Typed allocator, with the branch's default 10 µs poll budget and with polling disabled:

w base branch speedup branch, --idle-spin 0
1 1.89 1.77 1.07x
2 2.00 1.66 1.20x
3 2.90 2.04 1.42x 2.15
4 4.00 2.49 1.61x 2.71
6 6.64 3.55 1.87x 3.86
8 11.03 6.15 1.79x 5.81
12 19.10 12.09 1.58x 11.13
16 28.97 16.55 1.75x 17.78
24 43.56 25.02 1.74x 28.66
32 74.85 61.67 1.21x 68.35

Zero-copy allocator:

w base branch speedup
1 1.25 1.24 1.01x
2 2.25 2.01 1.12x
3 2.76 2.46 1.12x
4 3.25 2.87 1.13x
6 4.25 3.64 1.17x
8 6.12 4.62 1.32x
12 11.99 8.76 1.37x
16 17.89 11.74 1.52x
24 31.30 18.85 1.66x
32 54.57 48.21 1.13x

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 --idle-spin 0 caveat: it does not reproduce as a regression against master at any worker count. Three workers give 2.15 against master's 2.90 and four give 2.71 against 4.00, so on Linux the bulk of the typed win comes from the other commits rather than from polling. The sign of the spin-versus-no-spin delta then flips with worker count: no-spin is faster at 8 and 12 workers (5.81 against 6.15, 11.13 against 12.09) and slower at 16, 24, and 32 (17.78 against 16.55, 28.66 against 25.02, 68.35 against 61.67). That is a spread of roughly 8 to 13% in both directions, so on this machine the poll budget is close to neutral for the barrier past eight workers. At four workers --idle-spin 1 gives 2.53 against the default's 2.49, matching the claim that a 1 µs budget captures nearly all of the gain.

examples/pingpong.rs with 200k rounds, one element, four workers: typed 1.82 to 1.21 seconds, zero-copy 1.84 to 1.16 seconds.

🤖 Posted by Claude Code

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.

2 participants