turbo-tasks: batch invalidation via Invalidator::invalidate_all (#100485)
### What?
Adds a batch invalidation API and uses it wherever many tasks are
invalidated at once:
- `Invalidator::invalidate_all`, `invalidate_all_with_reason` and
`invalidate_all_with_reasons` invalidate a set of invalidators as one
batch.
- Duplicates are fine: task ids are deduplicated once.
- Empty input does nothing, and records no reasons.
- `TurboTasksApi::invalidate_many` / `invalidate_many_with_reasons`
(implemented by `TurboTasks`) make one `Backend::invalidate_tasks` call,
which previously had no in-tree callers.
- Reasons are recorded once, before any work starts.
- `TurboTasksBackend::invalidate_tasks` splits large batches into chunks
and invalidates them in parallel, using the same sizing as
`parallel::for_each_owned` (via the now-public
`parallel::for_each_chunk`). Batched invalidations report the
`Invalidator` dirty cause, like single ones.
- Converted call sites:
- File watcher: all invalidators of one event batch go in one call, with
one `WatchChange` reason per path. There is now one `file change` span
per batch instead of one per invalidator. Its `paths` field lists the
changed paths, comma separated, capped at 20 with `, ... (N more
paths)`. The call still happens under the existing invalidation lock.
- `DiskFileSystem`: whole-filesystem, subtree and after-write
invalidation. These replace the per-invalidator
`parallel::for_each_owned` calls and per-invalidator reason clones.
- `State::set`: all dependents in one call.
- A new `state_fan_out` criterion bench in
`turbo-tasks-backend/benches`.
### Why?
Every multi-task producer called `Invalidator::invalidate` once per
task. Each call went through `Backend::invalidate_task` →
`invalidate(smallvec![id])`, which runs its own operation and
scheduling. So none of these producers got the batched scheduling from
#100446 (one scheduler queue lock per batch instead of per task), or the
batched restores of paged-out tasks from #100465, which this PR builds
on.
### How?
- **Dirty priority across helper threads.** When an invalidation comes
from inside a task (e.g. `State::set` in a task), the dirtied tasks get
that task's priority as their parent priority, which the backend reads
from the task-local task state. The parallel helper threads don't have
it. So `invalidate_tasks` reads the priority once, before parallelizing,
and gives every chunk an `ExecuteContext` with that priority
(`execute_context_with_priority`;
`ExecuteContext::get_current_task_priority` returns it instead of the
task-local). Nothing else of the task state is needed, and other
contexts are unchanged.
- **Parallel work inside `with_turbo_tasks`.** `scope_bounded` wrapped
every job, including jobs it runs on the calling thread, in
`turbo_tasks_scope`, which panics while `with_turbo_tasks` holds a
borrow of the instance. Jobs now only enter the scope when the thread
doesn't already have that same instance. This lets
`State::notify_mutated` keep using `with_turbo_tasks`.
- **Unique ids at the backend.** `invalidate_many*` requires unique ids.
The `Invalidator` helpers dedupe, because the watcher and the filesystem
can yield the same task for several paths. Debug builds assert
uniqueness for the whole batch before chunking and again in `invalidate`
for each chunk; release builds pass the ids through without collecting
them.
### Benchmark
```
cargo bench -p turbo-tasks-backend --bench mod -- state_fan_out
```
- **Setup:** one `State` read by N dependent tasks. Each iteration times
`State::set` plus a strongly consistent read of the root sum, and
asserts the result.
- **Variants:** `resident`, and `evicted`, where everything is
snapshotted and evicted before each iteration. That eviction step is not
timed, and it asserts that at least N tasks were evicted.
- **Comparison:** A/B against this PR's parent on `canary`, with the
same bench copied in. Six paired runs on one 8-vCPU VM, alternating
which side runs first.
- **Revision:** measured on an earlier revision of this PR, before it
was rebased onto #100465 (batched restores in `invalidate`), and while
the chunking still lived in `TurboTasks`. The A/B wasn't re-run on the
current revision, so the numbers don't include #100465's effect on
either side.
- **Statistics:** the change is the geometric mean of the per-pair
ratios, with a 95% t-interval.
| variant / N | median base | median this PR | change (95% CI) |
|---|---|---|---|
| resident / 1 | 0.284 ms | 0.288 ms | −7.6% [−32.0%, +25.5%] |
| resident / 1,000 | 5.278 ms | 5.023 ms | −1.3% [−5.7%, +3.2%] |
| resident / 10,000 | 61.045 ms | 58.029 ms | −4.7% [−8.3%, −1.0%] |
| evicted / 1 | 0.258 ms | 0.265 ms | +3.3% [−0.2%, +7.0%] |
| evicted / 1,000 | 8.295 ms | 6.760 ms | **−19.4%** [−21.6%, −17.2%] |
| evicted / 10,000 | 91.967 ms | 69.775 ms | **−21.8%** [−25.1%, −18.4%]
|
- **Evicted:** a clear improvement when the dependents have to be
restored, from invalidating the chunks in parallel and batching their
scheduling (measured without #100465's batched restores).
- **Resident:** a small win at 10k, and no reliable difference at 1k or
for a single task.
- **Limits:** the intervals reflect repeatability on one VM, not across
machines. This is a `State` fan-out microbenchmark, not an end-to-end
filesystem measurement. The OS page cache was warm.
### Tests
- **`tests/invalidate_all.rs`:**
- a 2,000-task batch, large enough to chunk, re-executes every task
exactly once
- duplicates in one batch
- reasons show up in the update info
- empty batches are no-ops
- `State::set` inside a task with 2,000 dependents, and
`parallel::for_each` inside `with_turbo_tasks` (both panic without the
`scope_bounded` change)
- a 1,999-task batch invalidated from inside a task leaves every task
dirty with the same parent priority as one task invalidated on its own
(fails without the explicit priority: tasks of the helper-thread chunks
get `Initial`). Re-executions are held back so the dirty state can be
inspected.
- **Watcher:** the `paths` span formatting, including truncation.
<!-- NEXT_JS_LLM -->
<!-- fleet d21ddb86-de88-41df-82c7-aa6a29a6165e -->
---------
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>