Turbopack: release freed memory right after project shutdown (#101529)
## What?
After `project.shutdown()` resolves in a Turbopack `next build`, return
the memory that was freed by dropping the turbo-tasks storage to the OS
right away. Until now it stayed resident for another 6–10s.
Part of #100250.
## Why?
`TurboTasksBackend::stop` already drops the task storage
(`storage.drop_contents()`). Even so, on a large generated App Router
app (5000 routes, 32 CPUs) the main process stayed at **~12.5–13 GB
RSS** once shutdown resolved. It only fell to ~1 GB in steps over the
next 6–10s, while the static generation workers were already loading
their page bundles. That overlap directly raises the peak memory of the
build's process tree.
Root cause, found with RSS timelines, mimalloc commit stats and thread
counts:
- mimalloc keeps each page in the heap of the thread that allocated it.
A free from another thread only queues the block on that page, and the
owning thread has to collect its heap before the page becomes free.
- Tokio scheduler workers collect on every park (`on_thread_park` →
`TurboMalloc::thread_park`). **Blocking-pool threads have no such
hook.**
- Many blocking-pool threads used to be scheduler workers that did
compilation and persistence work. They handed their worker role to a new
thread in `block_in_place` (used by `scope_bounded`) and own a large
share of the heap. During shutdown the process grows from 44 to 75
threads.
- Those threads only collect when they exit after Tokio's 10s idle
keep-alive. RSS dropped in step with the thread count (75 → 44).
- A single `TurboMalloc::collect(true)` on one thread released only ~7.5
GB of the ~13 GB, because it cannot process pages owned by other live
threads.
- JS-held napi references were not the cause. The `Project` External is
garbage-collected ~1.5s after shutdown, and dropping it doesn't change
RSS.
## How?
- New `turbo_tasks::memory::release_freed_memory()`:
- It reads Tokio's `num_idle_blocking_threads` metric. This needs
`tokio_unstable`, which `.cargo/config.toml` already enables for all
targets.
- It runs one `spawn_blocking` task per idle blocking thread. A bounded
rendezvous makes the tasks land on distinct threads, and each one calls
`TurboMalloc::collect(false)` to process its own heap.
- A single `collect(true)` on a blocking thread then purges the freed
pages of all threads to the OS.
- `TurboMalloc::collect(true)` now initializes the calling thread's
mimalloc heap (`mi_thread_init()`) first.
- mimalloc's `mi_collect` skips everything, including the process-wide
purge, when the calling thread's heap is uninitialized.
- That is the case on a fresh blocking-pool thread that never allocated.
- It rarely happens on canary, where many initialized blocking threads
are idle. With fewer blocking threads (e.g. without `block_in_place`),
the purge was frequently a no-op and ~12 GB stayed resident.
- `mi_thread_init()` is idempotent, and `collect(false)` (the park hook)
is unchanged.
- It is best effort and cannot hang: the rendezvous is bounded (1s), and
any thread it misses still collects when it exits.
- `project_shutdown` calls it (traced as `release freed memory`) after
`stop_and_wait` and the exit handlers. Persistence and compilation-event
delivery are therefore already done. The JS thread is not blocked, and
the dev server's `project_on_exit` path is unchanged.
Alternatives evaluated for a more general mid-build/dev variant:
- Tokio has no idle hook for blocking-pool threads.
- Collecting at the end of `scope_bounded` / `block_in_place` is too
early. It can't process frees that arrive after the thread has gone
idle, and the new integration test demonstrates this.
- Shortening the blocking-pool keep-alive to 1s still left 5.4 GB at
shutdown resolution and would add thread churn.
### Tests
- `turbopack/crates/turbo-tasks/src/memory.rs` has unit tests:
- each idle blocking thread is visited exactly once and reused rather
than new threads being spawned;
- zero visits spawn no threads;
- a capped blocking pool times out instead of hanging.
- `turbopack/crates/turbo-tasks/tests/release_freed_memory.rs` is a
Linux test in its own binary with mimalloc as the global allocator:
- An idle blocking thread owns 256 MB of half-used pages. They are freed
from another thread, and the test checks they stay resident even after a
forced collect on both threads.
- `release_freed_memory()` then returns them to the OS.
- The test fails without the release call.
- `turbopack/crates/turbo-tasks-malloc/tests/forced_collect.rs` is a
Linux test in its own binary:
- 512 MB is freed on a thread that has since exited.
- `collect(true)` runs on a fresh thread that never allocated, and the
test checks the memory is returned.
- Without the `mi_thread_init()` fix it fails: 557 MB stays resident.
## Benchmarks
> **Update after rebasing onto canary f4ea7a70 (mimalloc v3.6.1,
#101518):** the tables below were measured on the previous allocator.
> - **Why this PR alone no longer reaches ~1 GB:** v3.6.1 defaults
`allow_thp` to 2. On Linux with THP available, its minimal purge size
becomes 2 MiB, so the small free ranges of the fragmented heap are never
purged, not even by the forced collect here. Details:
https://github.com/vercel/next.js/pull/101518#issuecomment-6819311577
> - **What this PR alone does on v3.6.1** (smoke, 32 CPUs): main RSS at
resolve 12.24 GB, vs 13.84 GB on canary.
> - **The fix is #101623**, at the top of the stack #101529 → #101589 →
#101623. It lowers mimalloc's purge minimum only around this PR's final
collect.
> - **Full stack vs canary** (`bench/large-app-router`, cold, n=3): main
RSS at resolve 1.05 ± 0.04 GB (16 CPUs) and 1.17 ± 0.01 GB (32 CPUs), vs
13.15 / 14.49 GB. Peak tree RSS −5.74 / −13.24 GB.
> - The `mi_thread_init` fix here is still needed on v3.6.1:
`tests/forced_collect.rs` still fails without it.
Setup:
- Same machine (32 vCPU = 16 physical cores × 2 SMT, 64 GB RAM), cold
builds (`.next` removed), `TURBO_ENGINE_IGNORE_DIRTY=1`.
- next-swc built natively in release mode for both base and change, on
the same canary commit (4d51724b7c).
- Base and change interleaved within each iteration, with the order
alternating between iterations.
- 16 CPUs = `taskset -c 0,2,…,30` (one per physical core); 32 CPUs =
`0-31`.
- RSS sampled from `/proc` every 100 ms. "Tree" is main + all workers.
- Values are mean ± stddev.
### `bench/large-app-router` (generated, 5000 routes; n=6 per cell)
| CPUs | | wall (s) | peak tree RSS (GB) | main RSS at `shutdown()`
resolve (GB) | +1s | +3s | peak tree RSS after shutdown (GB) |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 16 | base | 148.4 ± 25.9 | 24.08 ± 1.77 | 12.32 ± 1.16 | 12.32 ± 1.16
| 12.32 ± 1.16 | 23.09 ± 1.28 |
| 16 | **new** | 141.9 ± 26.8 | **22.11 ± 2.42** | **0.92 ± 0.07** |
0.92 ± 0.07 | 0.92 ± 0.07 | **13.07 ± 0.12** |
| 32 | base | 126.2 ± 17.6 | 23.11 ± 4.82 | 12.56 ± 0.80 | 9.03 ± 2.76 |
5.24 ± 0.68 | 18.89 ± 0.30 |
| 32 | **new** | 118.3 ± 17.7 | **21.55 ± 4.09** | **2.37 ± 3.42**¹ |
1.32 ± 0.84 | 0.97 ± 0.08 | 18.80 ± 0.13 |
¹ 0.8–1.1 GB in 5 of 6 runs. In one run, memory freed after the sweep
took until +2.6s to be purged.
Machine speed drifted between iterations, so absolute walls vary a lot.
The paired (same-iteration) new − base deltas are more reliable:
| app | CPUs | Δ wall (s) | Δ peak tree RSS (GB) |
| --- | --- | --- | --- |
| large-app-router | 16 | −6.6 ± 8.7 | −2.0 ± 3.0 |
| large-app-router | 32 | −7.8 ± 2.5 | −1.6 ± 6.3 |
| nested-deps-app-router-many-pages | 16 | −0.2 ± 2.4 | −3.3 ± 1.2 |
| nested-deps-app-router-many-pages | 32 | +0.1 ± 1.7 | −1.7 ± 0.6 |
### `bench/nested-deps-app-router-many-pages` (n=4 per cell)
| CPUs | | wall (s) | peak tree RSS (GB) | main RSS at `shutdown()`
resolve (GB) | peak tree RSS after shutdown (GB) |
| --- | --- | --- | --- | --- | --- |
| 16 | base | 37.4 ± 1.6 | 14.56 ± 1.18 | 3.72 ± 1.36 | 14.56 ± 1.18 |
| 16 | **new** | 37.3 ± 1.0 | **11.29 ± 0.12** | **0.41 ± 0.01** | 11.29
± 0.12 |
| 32 | base | 32.9 ± 0.7 | 16.99 ± 0.57 | 2.57 ± 0.64 | 16.99 ± 0.57 |
| 32 | **new** | 33.1 ± 1.4 | **15.27 ± 0.03** | **0.47 ± 0.00** | 15.27
± 0.03 |
The tables above were measured before the fresh-thread `collect(true)`
fix. A release-build sanity run with the fix (large-app-router, 32 CPUs)
measured:
- wall: 96.6 s
- peak tree RSS: 19.0 GB
- main RSS at `shutdown()` resolve: 0.98 GB
Peak main-process RSS during compilation is unchanged (14.0–14.2 GB on
large-app-router, ~4.5 GB on nested-deps). No configuration showed a
peak RSS regression.
### Note for work that starts static workers earlier (e.g. a worker fork
server)
- This only releases memory **after** `project.shutdown()` resolves:
from about 13 GB to about 1 GB within ~1.3s of the storage drop.
- `project.shutdown()` itself takes 22–30s on large-app-router, almost
entirely writing the filesystem cache.
- Page-data collection already runs concurrently with that write. The
main process necessarily holds its full ~13.6 GB heap until the write
finishes.
- The bimodal ~27 GB peak tree RSS on large-app-router always occurs
inside that window, before shutdown resolves, in both base and new.
- Workers that load bundles during that window will still overlap with
the full Turbopack heap. Reducing memory during the final persist is
separate work.
## Verification
- `cargo test -p turbo-tasks --lib memory`
- `cargo test -p turbo-tasks --test release_freed_memory`
- `cargo test -p turbo-tasks-malloc`
- `cargo clippy -p turbo-tasks -p turbo-tasks-malloc -p
next-napi-bindings --all-targets`
- `cargo check -p turbo-tasks -p turbo-tasks-malloc --lib --target
wasm32-wasip1-threads`
- `pnpm test-start-turbo
test/e2e/filesystem-cache/build-cache-default.test.ts` (persistent build
cache still written and read back)
- Manual: a warm rebuild of nested-deps reuses the filesystem cache
(compile 16.4s → 5.5s).
- Not run: the browser-based `filesystem-cache-app-changes` tests (no
Playwright browser available in my environment).
<!-- NEXT_JS_LLM -->
<!-- fleet 0e159516-3c7f-4582-a520-97d8b272ee82 -->
---------
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>