Skip to content

turbo-tasks: avoid redundant aggregation roots and root-upper optimization cascades - #101604

Merged
sokra merged 1 commit into
canaryfrom
fleet/turbo-tasks-root-uppers
Nov 12, 2026
Merged

sokra merged 1 commit into
canaryfrom
fleet/turbo-tasks-root-uppers

Conversation

@sokra

@sokra sokra commented Nov 12, 2026

Copy link
Copy Markdown
Member

Summary

Two fixes in turbo-tasks-backend for cases where reading already computed tasks from a new root, or from a parentless turbo_tasks::run closure, restructures large parts of the aggregation graph. Context: #100250. The motivating pattern is a new root operation reading the existing endpoint outputs after the emit, like the route bundle stats in #101540.

A: count all root uppers as one upper in optimize_task

optimize_task raises a task's aggregation number when (uppers - 1) * followers is too large. A root aggregates its whole subgraph, and raising the number never removes an upper edge to a root, yet root uppers were still counted.

So every additional root over an existing subgraph made large parts of it look like they needed optimization. The resulting raises cascaded into a rebalance of the subgraph without reducing upper edges. The old code already counted a root_uppers variable but never used it, and it also counted root followers.

The fix:

  • Count only root uppers, persistent or transient.
  • Treat them as a single upper when evaluating the threshold and seeding the search.

Behavior with at most one root upper is unchanged.

B: parentless calls no longer make persistent callees aggregation roots

connect_child made every child connected without a parent task (e.g. called from a turbo_tasks::run closure) an aggregation root, so each one aggregated its entire, possibly large and existing, subgraph.

Making the callee a root is only needed to shield a persistent graph from a transient upper, and a parentless connect has no upper. Persistent tasks that need to be roots are already made roots at creation: strongly consistent reads and collectibles reads are only allowed on root functions.

What changes and what doesn't:

  • Parentless persistent callees are no longer promoted to roots.
  • Parentless transient tasks are still made roots.
  • The transient parent β†’ persistent child promotion is unchanged.
  • The parentless transient-ref pin (GC) is unchanged.

Benchmarks

Machine: 32 vCPU (16 cores Γ— 2 SMT), 64 GB RAM. Pristine canary 2be83539 vs this PR, both built locally as release next-swc. TURBO_ENGINE_IGNORE_DIRTY=1.

  • 16 CPUs means one thread per physical core (taskset -c 0,2,…,30); 32 CPUs is 0-31.
  • Each round runs canary cold β†’ canary warm β†’ PR cold β†’ PR warm, with the order alternated per round. A warm build always follows a cold build with the same binary.
  • 6 runs per cell over 2 blocks of 3 rounds. Values are mean Β± sample stddev.
  • Peak RSS is GNU time %M, which is the largest single process's ru_maxrss.
  • Apps: bench/large-app-router (5,102 routes, from bench: add generated large App Router benchmark for Turbopack scalingΒ #101502, not part of this PR) and bench/nested-deps-app-router-many-pages.

Microbenchmark (new turbo_tasks_backend_existing_outputs criterion bench, 32 threads):

Shape canary PR
New root operation reading 500 existing endpoints 2.216 s 67.2 ms
run closure calling 50 existing endpoints 14.844 s 58.5 Β΅s

next build: no significant change on canary's own build, as expected. Canary rarely layers roots over existing graphs or calls existing tasks from run. The VM was noisy in the second block, hence the large stddev on large-app-router.

App CPUs Build canary compile (s) PR compile (s) canary peak RSS (GiB) PR peak RSS (GiB)
large-app-router 16 cold 85.23 Β± 16.22 80.91 Β± 17.48 14.085 Β± 0.070 14.058 Β± 0.144
large-app-router 16 warm 11.97 Β± 2.11 12.01 Β± 2.07 5.274 Β± 0.029 5.293 Β± 0.023
large-app-router 32 cold 61.29 Β± 11.83 62.52 Β± 12.65 14.450 Β± 0.190 14.525 Β± 0.148
large-app-router 32 warm 12.69 Β± 1.99 12.60 Β± 2.18 5.380 Β± 0.007 5.407 Β± 0.021
nested-deps 16 cold 17.55 Β± 0.57 17.48 Β± 0.91 4.891 Β± 0.045 4.912 Β± 0.039
nested-deps 16 warm 3.03 Β± 0.16 3.00 Β± 0.15 1.927 Β± 0.007 1.933 Β± 0.008
nested-deps 32 cold 15.28 Β± 0.43 15.27 Β± 0.60 5.243 Β± 0.041 5.236 Β± 0.031
nested-deps 32 warm 3.08 Β± 0.09 3.00 Β± 0.12 2.032 Β± 0.022 2.039 Β± 0.014

Peak memory. Cold-build deltas change sign between blocks and are within stddev. The large-app-router warm builds showed a small positive delta (+0.36% at 16 CPUs, +0.51% at 32 CPUs, about +20–30 MiB) in both blocks. I re-checked that with a controlled protocol:

  • Each binary populated its own cache once.
  • Every warm build started from a fresh copy of that binary's cache, with the binary order rotated.

There the PR was not higher: canary 5.024 Β± 0.025 GiB (n=6), PR 5.009 Β± 0.031 GiB (n=5; one run hit a full disk and is excluded). Builds with only A or only B were also not higher: 5.018 Β± 0.032 and 5.003 Β± 0.035 GiB.

The persisted cache size differs by +2–4 MB (0.1%). I couldn't attribute the warm delta in the standard protocol to the change, but I'm disclosing it here rather than claiming a proven memory non-regression.

Raw data (wall time and raw peak RSS in KiB)
App CPUs Build Side n next build wall (s) Turbopack compile (s) Peak RSS (GiB) Raw peak RSS (KiB)
large-app-router 16 cold canary 6 117.29 Β± 21.28 85.23 Β± 16.22 14.085 Β± 0.070 14748444, 14756180, 14682312, 14715620, 14879648, 14831476
large-app-router 16 cold PR 6 111.69 Β± 22.82 80.91 Β± 17.48 14.058 Β± 0.144 14864528, 14828944, 14521200, 14804204, 14848396, 14576552
large-app-router 16 warm canary 6 46.18 Β± 8.25 11.97 Β± 2.11 5.274 Β± 0.029 5547560, 5503468, 5568224, 5485912, 5533124, 5544924
large-app-router 16 warm PR 6 45.61 Β± 7.68 12.01 Β± 2.07 5.293 Β± 0.023 5554808, 5572920, 5508312, 5542580, 5574488, 5548376
large-app-router 32 cold canary 6 94.25 Β± 17.31 61.29 Β± 11.83 14.450 Β± 0.190 15003328, 14940072, 15022976, 15324424, 15442420, 15176724
large-app-router 32 cold PR 6 95.23 Β± 18.98 62.52 Β± 12.65 14.525 Β± 0.148 15063352, 15456324, 15066216, 15185004, 15278384, 15334480
large-app-router 32 warm canary 6 42.37 Β± 6.94 12.69 Β± 1.99 5.380 Β± 0.007 5650824, 5641980, 5632780, 5647752, 5631120, 5641204
large-app-router 32 warm PR 6 41.77 Β± 7.09 12.60 Β± 2.18 5.407 Β± 0.021 5675184, 5650084, 5692592, 5691828, 5670964, 5638724
nested-deps 16 cold canary 6 33.31 Β± 1.22 17.55 Β± 0.57 4.891 Β± 0.045 5141348, 5141192, 5156360, 5062332, 5082012, 5187972
nested-deps 16 cold PR 6 33.50 Β± 0.95 17.48 Β± 0.91 4.912 Β± 0.039 5071888, 5187476, 5152720, 5181464, 5156076, 5156444
nested-deps 16 warm canary 6 19.85 Β± 0.46 3.03 Β± 0.16 1.927 Β± 0.007 2016172, 2023936, 2021464, 2006508, 2024692, 2027728
nested-deps 16 warm PR 6 19.98 Β± 0.68 3.00 Β± 0.15 1.933 Β± 0.008 2021992, 2031716, 2040216, 2031820, 2017280, 2020152
nested-deps 32 cold canary 6 29.03 Β± 1.06 15.28 Β± 0.43 5.243 Β± 0.041 5421072, 5485836, 5540504, 5531136, 5517580, 5490448
nested-deps 32 cold PR 6 28.80 Β± 0.79 15.27 Β± 0.60 5.236 Β± 0.031 5537424, 5512692, 5475196, 5465476, 5452224, 5500664
nested-deps 32 warm canary 6 15.37 Β± 0.69 3.08 Β± 0.09 2.032 Β± 0.022 2103640, 2108604, 2140560, 2166760, 2127688, 2135156
nested-deps 32 warm PR 6 15.21 Β± 0.31 3.00 Β± 0.12 2.039 Β± 0.014 2136568, 2139084, 2117848, 2160420, 2145676, 2130756

Controlled warm protocol (large-app-router, 32 CPUs), raw peak RSS in KiB:

  • canary: 5306664, 5263920, 5255112, 5250808, 5237676, 5293352
  • PR: 5307460, 5231232, 5234152, 5257332, 5233808 (one run failed with ENOSPC)
  • A only: 5251608, 5253004, 5308064, 5297156, 5227408, 5234332
  • B only: 5262252, 5291680, 5237740, 5184800, 5267560, 5230364

On the #101540 pattern (stats over 5,102 existing endpoint outputs from a new root op), an earlier exploratory run with A enabled through an env toggle reduced that step from 4.18 Β± 0.63 s to 3.54 Β± 0.18 s (n=3, 32 CPUs). B removes the much larger cliff of the tt.run variant of that pattern: 50 endpoints in a synthetic repro went from 64 s to 0.1 ms.

Verification

  • New unit tests in aggregation_update.rs:
    • additional root uppers, persistent and transient, don't trigger optimization;
    • root followers aren't counted as root uppers;
    • mixed root and non-root uppers;
    • follower debt;
    • non-root uppers still optimize.
  • New unit tests in connect_child.rs:
    • a parentless persistent child isn't made a root;
    • parentless transient children and children of transient parents still are.
  • New integration tests, tests/existing_outputs_from_new_roots.rs:
    • correct values and invalidation through a new root operation over an existing subgraph;
    • parentless calls of existing tasks don't make them roots, are cache hits, and are still recomputed on invalidation;
    • parentless calls still pin a task against GC while unrelated disconnected tasks are collected.
  • The fix-A unit tests and the fix-B tests fail on canary.
  • cargo nextest run -p turbo-tasks-backend: 456 passed, also with --features verify_aggregation_graph.
  • cargo test -p turbo-tasks-backend --test existing_outputs_from_new_roots.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 498fd5c
View workflow run

@github-actions github-actions Bot added created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. labels Nov 12, 2026

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vercel Agent reviewed 67767ce and found no issues.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
@sokra
sokra force-pushed the fleet/turbo-tasks-root-uppers branch from 67767ce to 498fd5c Compare November 12, 2026 07:53

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vercel Agent reviewed 498fd5c and found no issues.

Comment on lines +76 to +84
// Parentless connects (e.g. calls from a `turbo_tasks::run` closure) have no upper at all, so
// there is nothing to shield the persistent graph from. Persistent tasks that need to be roots
// (strongly consistent reads and reading collectibles are only allowed on `root` functions)
// are already made roots when they are created. Making every parentless persistent callee a
// root would make each of them aggregate its whole (possibly large and existing) subgraph.
let should_make_root = match parent_task_id {
None => child_task_id.is_transient(),
Some(parent_task_id) => parent_task_id.is_transient() && !child_task_id.is_transient(),
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the logic in the comment, parentless transient tasks also doesn't need to be roots. Is that correct?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think so. Strongly consistent and collectibles reads are guarded by native_fn.is_root, and that applies to transient function tasks too, since their task type is set in initialize_new_task. root functions are rooted at creation, and Root/Once transient tasks start with the max aggregation number. So the parentless-transient arm is redundant. I only kept it to limit the behavior change to the measured persistent case. I'll send a follow-up that drops it.

@sokra
sokra marked this pull request as ready for review November 12, 2026 08:30
@sokra
sokra merged commit f45a61f into canary Nov 12, 2026
108 checks passed
@sokra
sokra deleted the fleet/turbo-tasks-root-uppers branch November 12, 2026 08:30
sokra added a commit that referenced this pull request Nov 12, 2026
…101614)

## Summary

Follow-up to #101604, from [this review
comment](#101604 (comment)).

#101604 stopped making parentless persistent callees, such as tasks
called from a `turbo_tasks::run` closure, aggregation roots. This PR
does the same for parentless **transient** callees.

The same argument applies to transient tasks:
- A parentless connect has no upper, so there is nothing to shield.
- Tasks that need to be roots already are:
- Strongly consistent reads and collectibles reads are only allowed on
`root` functions. The check is based on the task's function and applies
to transient function tasks as well.
- `root` functions are made roots when they are created, whether
persistent or transient.
- Top-level transient tasks (root and once tasks) are created with the
maximum aggregation number.

Now `connect_child` only makes a task a root when a transient parent
calls a persistent child. That rule shields the persistent graph from
the transient caller and is unchanged.

The "already constructed root" shortcut is removed. It only applied to
the parentless transient case, so it is dead code now.

## Verification

- `connect_child` unit test: a parentless transient child is not made a
root. This test is flipped from the previous behavior.
- New integration test
`parentless_calls_of_transient_tasks_are_only_roots_if_root_functions`:
- A transient non-root function called from `run` is not a root, and is
still held by a transient ref.
  - A transient `root` operation called from `run` is a root.
- Strongly consistent reads of that operation return correct values
before and after an invalidation.
  - It fails on canary on the non-root assertion.
- `cargo nextest run -p turbo-tasks-backend`: 456 passed, also with
`--features verify_aggregation_graph`.

<!-- NEXT_JS_LLM -->


<!-- fleet 646973d0-3866-4b17-a7a8-d4e4971dc4dd -->

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant