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>