next.js
33b7edfc - turbo-tasks: execute scheduled tasks inline when they are read (#96808)

Commit
20 days ago
turbo-tasks: execute scheduled tasks inline when they are read (#96808) ### What? Two changes to how the async turbo-tasks engine handles a read whose value isn't ready yet: 1. When a read finds its task merely *scheduled* (queued, not started), it takes the task out of the scheduler queue and executes it on the reading thread instead of waiting for a worker to pick it up. If the execution completes without yielding, the read returns synchronously; if it yields, the partially-polled execution is handed to tokio and the read waits as before. 2. When a read finds its task's execution already **in progress** (a worker is running it), it now waits directly on the task's completion event instead of also attempting to take it over — avoiding a futile acquisition of the scheduler's queue lock, which is otherwise the most contended lock in the system. An inline execution is visible in traces: the executed task's span carries `inline_execution = "complete"` when the reader's poll finished it, or `"partial"` when it yielded and was handed to the runtime. A task a worker executed leaves the field unset. Optional diagnostics (`InlineExecutionStats`) count queue pushes, claim attempts and their outcomes, and reads that waited for an already-running task. They are behind the `inline_execution_stats` Cargo feature, off by default, so a normal build has no counters, no atomics and no extra fields. ### Why? A large share of read misses target tasks that have merely been scheduled, not tasks actually being computed elsewhere. Parking on those adds two avoidable thread hops (schedule → worker pickup → wake) for work the reader could simply do itself. Conversely, a read of a task a worker is already executing gains nothing from trying to claim it, so it shouldn't pay for trying. ### How? - `PriorityRunner` gained a keyed claim: queued entries are indexed by a `ScheduleKey` (task or local task) and stored in a slot store, so one entry can be removed by key under the existing queue lock while the priority heap keeps a tombstone that a popping worker skips. Every scheduled execution still runs exactly once. - The backend already distinguished a queued task from one already executing (`InProgressState::Scheduled` vs. `InProgress`) when building a listener for a read, and discarded that distinction. It's now threaded through as `ReadOutcome<T> = Value(T) | Scheduled(EventListener) | InProgress(EventListener)`; only `Scheduled` reads attempt a claim. The state is a hint — a worker pops a task off the queue before it marks it started, so a read can see `Scheduled` for a task that is no longer claimable. Acting on a stale hint costs one failed claim, never correctness. - Inline execution nests (A reads B inline, B reads C inline, ...), so it's capped at 16 levels per thread to bound stack growth; at the cap, reads fall back to waiting for a worker as before. The cap is a thread-local counter because it guards the *thread* stack, not the task stack. - Recording the outcome on the *executed task's* span needs a small handoff: when `poll_once_or_spawn` returns, that task's span has already been exited, so the executor registers the span it instruments the task body with in a slot that only exists while a claimed task is polled inline. - Added `turbo-tasks-backend/tests/inline_read_execution.rs` (8 tests), `inline_execution_span.rs` and `inline_execution_span_worker.rs`, plus unit tests in `priority_runner.rs`/`manager.rs` covering keyed claim/exactly-once, inline completion for global and local tasks, the in-progress/scheduled distinction, nested inline executions, restoring from a persistent cache, and a deep dependency chain (stack-depth guard). ### Benchmarks `bench/nested-deps-app-router-many-pages` (1000 pages, 3020 generated components, 3010 routes), release builds of `@next/swc` for this branch and for its merge base, runs interleaved, first run of each variant discarded as warm-up. Shared 8-vCPU VM. **Cold `next build --turbopack`** — 5 measured runs per variant: | metric | canary (median) | this branch (median) | delta | | --- | ---: | ---: | ---: | | total build | 96.70 s | 89.29 s | **−7.7 %** | | turbopack compile phase | 68 s | 61 s | **−10.3 %** | Raw totals (ms) — canary: 93566, 95354, 96702, 96717, 97545; branch: 89070, 89140, 89287, 89892, 90462. The distributions don't overlap (the branch is faster in all 25 pairwise comparisons, exact two-sided Mann-Whitney p ≈ 0.008), and the 7.4 s median gap is about twice canary's own 4.0 s run-to-run spread. **Incremental build** (second build reusing the persistent cache) — 3 measured runs per variant: 36.14 s vs 37.29 s median, compile phase 10.4 s vs 10.5 s. The 1.2 s gap is *smaller* than canary's own 1.2 s spread, so this is **no measurable difference** — the incremental case is dominated by restore and by static generation in Node workers rather than by the scheduling this PR changes. **Cache-hit reads** (`task_overhead/turbo-cached-*`, criterion): no regression. Measured before the rebase, on the same read-path code — the hit path never enters this code, since the inline attempt only happens on the branch where a read reports a miss. Caveats: one app and one workload shape, on a shared VM, so only the relative comparison on the same machine is meaningful, not the absolute seconds. <!-- NEXT_JS_LLM --> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
Author
Parents
Loading