turbo-tasks-backend: stripe SnapshotCoordinator's operation count and poll for the drain (#100280)
## Summary
Every backend operation opens an `OperationGuard` on the
`SnapshotCoordinator`, which incremented and decremented a single shared
`AtomicUsize`. On many-core machines every worker writes that one cache
line many times per task. In #100250 it was the top leaf of a profile on
192 CPUs (55% of the emit step's samples), and striping it took 4.7–6.5s
off `run-turbopack` there.
This PR:
- **Counts active operations in cache-padded per-thread stripes.** This
is @ejc3's change from ejc3/next.js#8 (patch 52 in #100250),
cherry-picked with their authorship. The snapshot flags move to their
own read-mostly word. Admission becomes a `SeqCst` store-load handshake:
an operation increments its stripe, then loads the flags; a closing
phase sets the running bit, then sums the stripes.
- **Polls for the drain instead of notifying from operation ends.** In
#8, while a snapshot waited, an operation that emptied its stripe summed
every stripe to decide whether to notify. That means cross-core reads of
every stripe on almost every operation end, for as long as the wait
lasts. Regular snapshots do wait under load (first after 300s, then
every 120s). Now:
- Operation ends only decrement (`Release`).
- The waiting phase retries closing admission on a backoff (20µs
doubling to 1ms).
- Each attempt keeps admission closed for up to 1/20 of the poll
interval, so in-flight operations can finish.
- Operations that hit the running bit back out their count before
waiting, so arrivals can't stop an attempt from seeing zero.
Without the grace window, a saturating load never let a phase close:
`stress_no_missed_wakeups` timed out after completing one snapshot in
60s.
- **Sizes the stripes at 2×
`turbo_tasks::parallel::available_parallelism()`** (capped at 512), so
`TURBO_TASKS_AVAILABLE_PARALLELISM` applies here like it does to the
backend's other sharded structures. Thread numbering keeps advancing as
blocking-pool threads come and go, so once it wraps, active threads land
on stripes at random.
- **Makes `OperationGuard` `!Send`.** An operation now ends on the
thread that began it, and the guard holds a reference to its stripe
rather than the coordinator plus an index. Moving a guard would still be
correct here, since stripes use atomic read-modify-writes, but nothing
needs to: every `ExecuteContext` is created and dropped inside a
synchronous backend method. The guard-migration stress test becomes a
shared-stripe stress test, and a new test asserts `!Send` at compile
time. #100281 relies on this.
- **Documents why the increment in `begin_operation` must stay
`SeqCst`**, unlike `Arc::clone`.
#100281, stacked on this, removes the remaining per-operation atomics
with an asymmetric memory barrier.
## Verification
- `cargo test -p turbo-tasks-backend --lib snapshot_coordinator`,
repeated 15× in debug and 10× in release on macOS.
- `cargo test -p turbo-tasks-backend --lib`.
- Not run: the 192-CPU benchmark from #100250 (no access to that
machine). The numbers above are from the issue and measure #8 alone,
without the polling change.
<!-- NEXT_JS_LLM -->
---------
Co-authored-by: ejc3 <ej.campbell@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>