next.js
2a697ac4 - turbo-tasks-backend: stripe SnapshotCoordinator's operation count and poll for the drain (#100280)

Commit
4 days ago
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>
Author
Parents
Loading