Repository navigation
turbo-tasks-backend: restore invalidated tasks with batched optional reads - #100465
Merged
Merged
Conversation
…reads Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Contributor
Tests PassedCommit: e8b5406 |
sokra
commented
Oct 20, 2026
lukesandberg
approved these changes
Oct 20, 2026
…duplicating Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Member
Author
|
Reviewed e8b5406 |
sokra
marked this pull request as ready for review
October 21, 2026 13:25
sokra
added a commit
that referenced
this pull request
Oct 21, 2026
) ### 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
invalidate()now opens its tasks with a bulk optional read (ExecuteContext::for_each_try_task_all) instead of onetry_taskper id. Non-resident persisted tasks are restored with one batched disk lookup per category instead of one lookup per task.Why?
Follow-up to #100446 (#100446 (comment)). The existing bulk read APIs (
prepare_tasks/for_each_task*) areMustExist: a task missing in memory and on disk panics. Invalidation needstry_tasksemantics instead, because a task being invalidated may legitimately be gone.How?
prepare_tasks_with_callbacktakes aTaskAccessmode.MustExistis the existing behavior and keeps the same panics and messages.AllowMissingskips any task thattry_taskwould not return, and the callback isn't called for it:for_each_try_task_allis built on that, sharing the guard and lock-counter handling withfor_each_task.invalidate()usesfor_each_try_task_all.debug_assert!checks this, with no deduplication cost in release builds. The no-duplicates contract is documented onBackend::invalidate_tasks, and every in-tree caller already passes unique ids.schedule_taskscall.In-tree,
invalidate()currently gets a single id from every caller (Invalidator→Backend::invalidate_task). Batching real multi-task invalidations (file watcher, filesystem invalidation,Statedependents) throughBackend::invalidate_tasksis a separate, independent PR, and it carries the benchmark. This PR makes no performance claim.Tests
for_each_try_task_allinbackend/operation/mod.rs:tests/invalidate_tasks.rs:Backend::invalidate_taskswith present, missing persistent and missing transient ids. Each existing task re-executes exactly once. This test also passes on the parent commit.cargo test -p turbo-tasks-backend,cargo clippy -p turbo-tasks-backend --all-targets -- -D warnings,cargo check -p turbo-tasks-backend --features task_dirty_cause