Skip to content

Turbopack: return freed memory eagerly while shutting down the project - #101589

Merged
sokra merged 3 commits into
canaryfrom
fleet/turbopack-eager-purge-on-shutdown
Nov 13, 2026
Merged

sokra merged 3 commits into
canaryfrom
fleet/turbopack-eager-purge-on-shutdown

Conversation

@sokra

@sokra sokra commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Based on canary: #101529 has been merged (ba894dd), and this PR is rebased onto it. #101623 is stacked on this PR: on mimalloc v3.6.1 it is needed for the post-shutdown release to reach ~1 GB again.

What?

While project.shutdown() persists and drops the turbo-tasks storage, mimalloc now returns freed memory to the OS immediately instead of after its purge delay. The main process's RSS therefore falls progressively during persistence instead of staying at ~13 GB until the end.

Why?

  • On a large app, next build spends 20–30 s in project.shutdown(), mostly persisting the filesystem cache.
  • Page-data collection (static workers) runs concurrently and loads its bundles meanwhile.
  • The storage is freed during that time, mostly by threads other than the ones that allocated it. Those pages only leave RSS after mimalloc's delayed purge, so the main process held its full heap for most of the shutdown span. That overlap is often where the build's peak process-tree RSS comes from.
  • Making mimalloc purge eagerly for the whole build costs much more system CPU (an earlier experiment measured +122 s), so this only applies during shutdown.

Part of #100250. A worker fork-server PR (#101586) will be benchmarked on top of this one.

How?

  • New API in turbo-tasks-malloc: TurboMalloc::enter_eager_purge_mode() -> EagerPurgeGuard.
    • While a guard is alive, mimalloc's purge_delay option is 0, so freed memory is returned right away. The previous value is restored when the last guard is dropped.
    • Guards are reference-counted across threads, and are also restored on unwinding or when the future holding one is dropped.
    • A delay that's already 0, or negative (purging disabled via MIMALLOC_PURGE_DELAY=-1), is left alone.
    • It's a no-op without mimalloc (wasm, miri, custom_allocator off).
  • Scoped option overrides: the set/restore logic lives in a reusable OptionOverride (turbo-tasks-malloc/src/option_override.rs). enter() returns a shared OptionOverrideGuard that ends the override on drop. Its state is None while no override is active. turbopack: return small free ranges to the OS after project shutdown #101623 reuses it for arena_purge_min_size. It uses mimalloc::sys::mi_option_purge_delay and the safe mimalloc::option_get/option_set from the official mimalloc bindings.
  • project_shutdown: enters the mode before the shutdown hooks and stop_and_wait (persistence), and ends it before release_freed_memory from Turbopack: release freed memory right after project shutdown #101529.
    • The order matters: while purge_delay is 0, mimalloc skips purges that were scheduled before the mode started, even for a forced collect.
    • Ending the mode first lets the final collect(true) return them (mi_arenas_try_purge returns early when the delay is <= 0).
  • Dev server: its default exit path (project_on_exit) is unchanged, so next dev never enters the mode.

Thread safety

mimalloc documents mi_option_set / mi_option_get as not thread-safe. The option is a plain long that allocator threads read without synchronization while it's changed, so this is formally a data race. We accept it as benign in practice, and option_override.rs documents it (including that options such as allow_thp, which also affect how memory is allocated, must not be overridden at runtime):

  • The value is a naturally aligned long, read and written without tearing on supported targets. A concurrent reader sees either the old or the new delay.
  • It only controls purge timing (whether a freed range is decommitted now or later), not allocation bookkeeping. Either value is correct, so there is no memory-safety consequence.
  • It changes only twice per shutdown (first guard, last guard).

Benchmarks

Update (mimalloc v3.6.1, canary f4ea7a7): the tables below predate canary's move to mimalloc v3.6.1 (#101518), with base = #101529 at 849dc68.

Re-measured on canary f4ea7a7 (bench/large-app-router, cold, n=3, interleaved on one boot, peak tree RSS as the 3-sample median):

CPUs arm wall (s) peak tree RSS (GB) main RSS when shutdown resolves (GB)
16 canary 102.4 ± 3.5 25.42 ± 0.27 13.19 ± 0.37
16 this PR only (without #101529) 101.0 ± 4.4 19.90 ± 0.26 6.46 ± 1.52
32 canary 79.9 ± 3.3 32.54 ± 0.37 14.39 ± 0.34
32 this PR only (without #101529) 78.7 ± 4.7 25.87 ± 0.49 7.66 ± 0.51

The full stack (#101529 + this PR + #101623) vs canary is in #101623: peak tree RSS −5.74 ± 0.25 GB at 16 CPUs and −13.24 ± 0.39 GB at 32 CPUs on cold builds, main RSS at resolve 1.05–1.17 GB, and no wall regression.

Setup:

  • bench/large-app-router (a generated app with 5000 routes) on one machine with 32 vCPUs (16 physical cores × 2 SMT) and 64 GB RAM.
  • Release native next-swc. Base = Turbopack: release freed memory right after project shutdown #101529 head (849dc68), new = this PR.
  • 16 CPUs = taskset -c 0,2,…,30 (one per physical core); 32 CPUs = 0-31.
  • Each iteration runs a cold build (.next removed) and then a warm build (persistent cache) per variant, with variant order alternating. Results are 6 interleaved paired iterations per cell, mean ± stddev; Δ is the paired difference new − base.
  • TURBO_ENGINE_IGNORE_DIRTY=1.
  • RSS is sampled from /proc every 100 ms. "Shutdown span" is projectShutdown called → resolved.

Cold build

CPUs wall (s) shutdown span (s) main sys CPU (s) peak tree RSS (GB) peak tree RSS, 3-sample median (GB) peak main RSS (GB) main RSS @ 50% of span @ 75% @ 90% @ resolve
16 base 117.5 ± 5.6 22.7 ± 2.1 84.7 ± 7.8 24.29 ± 2.83 22.50 ± 1.68 14.18 ± 0.28 13.80 ± 0.44 13.32 ± 0.65 13.14 ± 0.64 0.92 ± 0.05
16 new 118.9 ± 3.3 23.4 ± 3.2 85.1 ± 4.5 18.37 ± 1.76 18.36 ± 1.77 13.74 ± 0.14 13.25 ± 0.26 10.29 ± 2.36 7.22 ± 1.50 0.93 ± 0.06
16 Δ +1.5 ± 5.5 +0.6 ± 3.7 +0.4 ± 10.1 −5.92 ± 4.15 −4.15 ± 2.96 −0.44 ± 0.30 −0.55 ± 0.55 −3.03 ± 2.63 −5.92 ± 1.80 +0.01 ± 0.09
32 base 96.2 ± 5.1 27.4 ± 1.7 286.6 ± 27.8 20.67 ± 2.92 20.65 ± 2.91 14.14 ± 0.11 13.55 ± 0.24 12.21 ± 1.75 11.99 ± 1.77 3.09 ± 2.03
32 new 97.3 ± 4.1 26.8 ± 4.2 290.3 ± 29.9 20.60 ± 3.17 19.23 ± 0.67 13.82 ± 0.04 13.20 ± 0.34 9.23 ± 2.79 6.85 ± 0.98 1.87 ± 1.83
32 Δ +1.1 ± 4.9 −0.6 ± 2.6 +3.8 ± 25.9 −0.08 ± 0.96 −1.43 ± 3.18 −0.32 ± 0.12 −0.34 ± 0.41 −2.98 ± 2.44 −5.14 ± 1.85 −1.22 ± 2.01

Warm build

CPUs wall (s) shutdown span (s) main sys CPU (s) peak tree RSS (GB) peak main RSS (GB) main RSS @ 50% of span @ 90% @ resolve
16 base 45.8 ± 2.2 12.8 ± 0.8 17.1 ± 1.4 12.77 ± 0.16 5.26 ± 0.01 1.44 ± 0.82 1.07 ± 0.36 0.63 ± 0.13
16 new 46.4 ± 3.1 13.3 ± 1.2 17.6 ± 1.6 12.79 ± 0.20 5.25 ± 0.01 0.82 ± 0.14 0.80 ± 0.14 0.66 ± 0.14
16 Δ +0.6 ± 3.4 +0.5 ± 1.3 +0.5 ± 2.2 +0.02 ± 0.22 −0.01 ± 0.01 −0.62 ± 0.88 −0.27 ± 0.33 +0.04 ± 0.20
32 base 41.2 ± 2.3 14.1 ± 1.5 21.3 ± 2.1 18.65 ± 0.07 5.28 ± 0.02 2.48 ± 0.87 1.62 ± 1.13 0.63 ± 0.04
32 new 40.9 ± 1.4 15.1 ± 0.6 21.1 ± 2.1 18.56 ± 0.09 5.26 ± 0.01 0.79 ± 0.05 0.76 ± 0.06 0.64 ± 0.05
32 Δ −0.4 ± 2.1 +1.0 ± 1.6 −0.2 ± 2.4 −0.09 ± 0.07 −0.03 ± 0.02 −1.69 ± 0.91 −0.85 ± 1.16 +0.01 ± 0.05

Approximate process-tree CPU (main process + reaped and live descendants), from the last 3 iterations only:

build CPUs Δ user CPU (s) Δ sys CPU (s)
cold 16 +47 ± 84 +5 ± 6
cold 32 +12 ± 83 +10 ± 21
warm 16 +7 ± 34 +2 ± 4
warm 32 +15 ± 36 +1 ± 4

Notes:

  • Main RSS during shutdown: in cold builds, main RSS at 90% of the shutdown span drops from ~12–13 GB to ~7 GB, and keeps falling as storage is freed.
  • Peak tree RSS, cold 16 CPUs: it drops 24.3 → 18.4 GB, because the main heap no longer overlaps the workers' bundle loading.
  • Peak tree RSS, 32 CPUs: it's unchanged on average. The build's peak is usually reached outside the shutdown span there. Peak tree RSS inside the shutdown span: 19.16 ± 3.90 → 18.09 ± 5.01 GB.
  • Fork spikes: sequential /proc sampling sometimes counts a just-forked child with the parent's RSS for a single sample (a ~13 GB one-sample spike). This happened in 2 base and 1 new run. The raw peak is reported, plus a 3-sample-median peak as a sensitivity check.
  • Warm 16 CPUs: peak tree RSS is +0.02 ± 0.22 GB (paired, n=6). That's ~0.2%, well within run-to-run noise. All other cells are lower.
  • Wall and system CPU: both are unchanged within noise.

next dev

next dev on the same app at 32 CPUs, 3 interleaved runs per variant (cold .next):

ready (ms) first request /s0/r1 (ms) tree RSS after 5 s idle (GB)
base 374 ± 55 5008 ± 422 0.79 ± 0.02
new 354 ± 12 5122 ± 367 0.80 ± 0.03

There is no measurable effect, as expected: next dev never enters the mode.

Tests

  • turbopack/crates/turbo-tasks-malloc/src/eager_purge.rs has a unit test for guard semantics:
    • Guard semantics: sets and restores the delay; out-of-order drops; guards held concurrently on other threads; restore on unwinding; a 0 or negative delay is left alone.
      No integration test for mimalloc's purge behaviour itself (dropped in review).

Verification

  • cargo test -p turbo-tasks-malloc (also with --no-default-features), cargo test -p turbo-tasks --lib memory, cargo test -p turbo-tasks --test release_freed_memory
  • cargo clippy -p turbo-tasks-malloc -p next-napi-bindings --all-targets
  • cargo check -p turbo-tasks-malloc --target wasm32-wasip1-threads
  • Benchmarks above (release native builds)

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: 3fc4fae
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 11, 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 4138cf9 and found no issues.

@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 6faf08b and found no issues.

Comment on lines +25 to +36
struct State {
/// Number of live guards.
active: usize,
/// The purge delay to restore when the last guard is dropped, or `None` if it wasn't
/// changed.
previous: Option<c_long>,
}

static STATE: Mutex<State> = Mutex::new(State {
active: 0,
previous: None,
});

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.

struct EagerPurgeActive {
  // active live guards - 1
  additional_active: usize,
  previous: c_long
}

static STATE: Mutex<Option<EagerPurgeActive>>

This would make more sense to me, eliminating unreachable states

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.

Done in 0a56696: the state is now Mutex<Option<ActiveOverride { additional_active, previous }>>, in a reusable OptionOverride (option_override.rs) that #101623 also uses for arena_purge_min_size.

@sokra
sokra requested a review from lukesandberg November 12, 2026 08:48
@sokra

sokra commented Nov 12, 2026

Copy link
Copy Markdown
Member Author

Could you measure this PR without #101529? Maybe #101529 isn't needed at all with this flag?

@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 0a56696 and found no issues.

@vercel-fleet-prod

Copy link
Copy Markdown
Contributor

Thanks!

State representation: done in 0a56696. The set/restore logic now lives in a reusable OptionOverride (turbo-tasks-malloc/src/option_override.rs) whose state is Mutex<Option<ActiveOverride { additional_active, previous }>>, so the unreachable states are gone. The new top-of-stack PR reuses it for a second option (see below).

This PR without #101529: measured on canary f4ea7a7 (mimalloc v3.6.1). Setup: bench/large-app-router, cold next build, n=3 per cell, arms interleaved on one boot, 16 physical / 32 logical CPUs. Peak tree RSS is the 3-sample median, to drop single-sample fork spikes.

CPUs arm wall (s) peak tree RSS (GB) main RSS when shutdown resolves (GB)
16 canary 102.4 ± 3.5 25.42 ± 0.27 13.19 ± 0.37
16 this PR only 101.0 ± 4.4 19.90 ± 0.26 6.46 ± 1.52
16 #101529 + purge-size fix 97.2 ± 0.5 21.92 ± 0.18 1.13 ± 0.01
32 canary 79.9 ± 3.3 32.54 ± 0.37 14.39 ± 0.34
32 this PR only 78.7 ± 4.7 25.87 ± 0.49 7.66 ± 0.51
32 #101529 + purge-size fix 76.6 ± 0.1 22.22 ± 1.04 1.33 ± 0.07

So the flag alone isn't enough: 6.5–7.7 GB stays resident after shutdown. Two reasons:

It does lower the 16-CPU peak. While purge_delay is 0, mimalloc purges freed ranges immediately whatever their size (mi_arena_schedule_purge), which sidesteps the 2 MiB minimum purge size that mimalloc v3.6.1 uses with THP (details in #101518 (comment)).

On v3.6.1, #101529 alone also no longer gets to ~1 GB, because of that 2 MiB minimum. The fix is #101623, stacked on this one, which lowers arena_purge_min_size only around the final collect, reusing OptionOverride. Full-stack numbers are in its description: peak tree RSS −5.7 GB at 16 CPUs and −13.2 GB at 32 CPUs on cold builds vs canary, and main RSS at resolve 1.05–1.17 GB.

@sokra
sokra added this pull request to stack #101635 November 12, 2026 13:11
Base automatically changed from fleet/turbopack-release-memory-after-shutdown to canary November 12, 2026 18:24
sokra added a commit that referenced this pull request Nov 12, 2026
## 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 f4ea7a7 (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:
#101518 (comment)
> - **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 (4d51724).
- 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>
vercel-fleet-prod Bot and others added 2 commits November 12, 2026 18:34
Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
Moves the purge_delay set/restore logic into a reusable OptionOverride, whose
state is None while no override is active.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
@sokra
sokra force-pushed the fleet/turbopack-eager-purge-on-shutdown branch from 0a56696 to c558ea9 Compare November 12, 2026 18:34

@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 c558ea9 and found no issues.

Address review: OptionOverride::enter returns a shared OptionOverrideGuard that
ends the override on drop, explain why eager purge mode has to end before the
final release, and drop the eager purge integration test.

Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>

@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 3fc4fae and found no issues.

@sokra
sokra marked this pull request as ready for review November 13, 2026 07:05
@sokra
sokra merged commit adb817a into canary Nov 13, 2026
231 of 233 checks passed
@sokra
sokra deleted the fleet/turbopack-eager-purge-on-shutdown branch November 13, 2026 07:09
sokra added a commit that referenced this pull request Nov 13, 2026
…101623)

> Stacked on #101589 (eager purge during shutdown), which is based on
canary. #101529 (release freed memory after shutdown) has been merged.
Stack: canary (with #101529) → #101589 → this PR.

## What?

Makes `release_freed_memory` (run at the end of `project.shutdown()` in
`next build`) also return the **small free ranges** of the fragmented
heap to the OS. Since canary moved to mimalloc v3.6.1 (#101518), it no
longer did.

- New `TurboMalloc::collect_and_purge_all()`: a forced collect that
temporarily lowers mimalloc's `arena_purge_min_size` option to one arena
slice (64 KiB), then restores it.
- It reuses the scoped option override from #101589
(`turbo-tasks-malloc/src/option_override.rs`).
- A value set explicitly with `MIMALLOC_ARENA_PURGE_MIN_SIZE` is left
alone.
- `release_freed_memory` uses it for its final collect.

## Why?

- mimalloc v3.6.1 defaults `allow_thp` to 2. On Linux with transparent
huge pages available (`enabled` not `[never]`, e.g. `madvise`),
`_mi_os_minimal_purge_size()` (`src/prim/os.c`) then returns the huge
page size, **2 MiB**, instead of 64 KiB.
- `mi_arena_try_purge` only purges free ranges of at least that size.
- After the turbo-tasks storage is dropped, the few remaining live
allocations split the freed memory into ranges mostly smaller than 2
MiB. Even `mi_collect(true)` leaves them resident.
- In a probe at the end of `release_freed_memory`
(`bench/large-app-router`, 32 CPUs, single runs):
- with the defaults, main `RssAnon` was 6.0 GB, of which ~4.4 GiB were
free but unpurged arena slices, with 126 MiB live;
  - with a 64 KiB minimum it was 1.24 GB.
- Details:
#101518 (comment)

## How?

**Scoped, not process-wide.** Lowering the minimum for the whole process
(e.g. `MIMALLOC_ARENA_PURGE_MIN_SIZE=64` or `allow_thp=1` at startup)
gave the same build memory win in an interleaved comparison, but
measurably cost at 32 CPUs:
- shutdown span +1.9 ± 0.6 s cold;
- wall +1.9 ± 0.8 s warm on `bench/large-app-router`.

It would also break up huge pages during the whole build and in `next
dev`.

**Why changing the option at runtime is safe** (mimalloc v3.6.1):
- `_mi_os_minimal_purge_size()` reads `arena_purge_min_size` on every
purge pass; nothing caches it. Only `has_transparent_huge_pages` is
computed once at init, and this doesn't change it.
- Its only caller is `mi_arena_try_purge`, which runs under
`_mi_process.arena_purge_guard`, so only one thread purges at a time.
- The value only selects which runs of the purge bitmap are visited.
Every range is still claimed atomically from `slices_free`, purged and
released per 64 KiB slice, with its purge bits cleared atomically.
  - There's no double decommit and no alignment assumption.
- Sub-2 MiB purges already happen in stock v3.6.1 (with `purge_delay =
0`, for ranges above `arena_purge_immediate_size`, and whenever THP is
`[never]`).
- The option write races with allocator threads reading it. mimalloc
documents `mi_option_set` as not thread-safe. As for `purge_delay` in
#101589, the race is benign:
- it's an aligned `long`, so readers see either the old or the new
value;
  - it only decides which free ranges are returned to the OS.
  - This is documented in `option_override.rs`.
- `allow_thp` must **not** be changed at runtime: it is also read when
allocating OS memory, to decide whether to use huge pages
(`src/prim/unix/prim.c`). That is documented too.

### Tests

- New `turbo-tasks-malloc/tests/purge_small_ranges.rs` (Linux, own test
binary):
- It fragments 512 MiB of 1 KiB blocks, keeping one block per MiB alive.
- It checks that `collect(true)` leaves the memory resident (when THP is
available) and that `collect_and_purge_all()` returns it.
- With `collect_and_purge_all()` reduced to `collect(true)`, it fails:
561 MB retained.
- Existing `turbo-tasks-malloc` tests, `turbo-tasks` memory tests,
clippy, rustdoc and the `wasm32-wasip1-threads` check pass.

## Benchmarks: full stack (#101529 + #101589 + this PR) vs canary
f4ea7a7

**Setup:**
- Linux, 16 physical / 32 logical CPUs, THP `madvise`.
- Release native builds, `TURBO_ENGINE_IGNORE_DIRTY=1`.
- Arms interleaved with rotating order on one boot, n=3 per cell, cold
build followed by warm build.
- 16 CPUs = one logical CPU per physical core.
- Mean ± stddev; Δ is paired.
- RSS sampled every 100 ms from `/proc`. "Peak tree RSS" is the whole
process tree, including workers.

### `bench/large-app-router` (generated, 5000 routes, from draft
#101502)

| build | CPUs | arm | wall (s) | compile (s) | after compile (s) | main
sys CPU (s) | peak tree RSS (GB) | main RSS when shutdown resolves (GB)
|
|---|---|---|---|---|---|---|---|---|
| cold | 16 | canary | 99.1 ± 5.5 | 72.0 ± 4.4 | 26.6 ± 1.0 | 38.2 ± 4.3
| 25.49 ± 0.22 | 13.15 ± 0.18 |
| cold | 16 | stack | 96.3 ± 6.4 | 69.7 ± 5.9 | 25.8 ± 0.8 | 40.6 ± 2.5
| **19.75 ± 0.11** | **1.05 ± 0.04** |
| cold | 16 | Δ | −2.8 ± 1.0 | −2.3 ± 1.5 | −0.8 ± 0.3 | +2.5 ± 2.7 |
**−5.74 ± 0.25** | −12.10 ± 0.16 |
| cold | 32 | canary | 81.1 ± 2.9 | 51.7 ± 1.2 | 28.8 ± 2.4 | 188.3 ±
12.7 | 32.64 ± 0.42 | 14.49 ± 0.49 |
| cold | 32 | stack | 79.2 ± 5.0 | 50.7 ± 2.5 | 27.9 ± 2.6 | 187.6 ±
25.6 | **19.40 ± 0.07** | **1.17 ± 0.01** |
| cold | 32 | Δ | −1.9 ± 2.1 | −1.0 ± 2.6 | −0.8 ± 2.0 | −0.7 ± 13.7 |
**−13.24 ± 0.39** | −13.32 ± 0.50 |
| warm | 16 | canary | 40.5 ± 2.7 | 10.2 ± 1.0 | 21.6 ± 1.3 | 13.8 ± 1.2
| 13.95 ± 0.06 | 1.62 ± 0.14 |
| warm | 16 | stack | 41.2 ± 3.0 | 10.2 ± 1.0 | 22.6 ± 2.0 | 15.9 ± 1.2
| **13.17 ± 0.13** | **0.84 ± 0.12** |
| warm | 16 | Δ | +0.8 ± 1.2 | 0.0 ± 0.2 | +1.0 ± 0.8 | +2.1 ± 0.6 |
−0.79 ± 0.07 | −0.78 ± 0.03 |
| warm | 32 | canary | 37.8 ± 3.5 | 10.5 ± 0.4 | 19.2 ± 3.0 | 19.6 ± 0.4
| 20.03 ± 0.21 | 1.77 ± 0.05 |
| warm | 32 | stack | 34.8 ± 2.2 | 10.1 ± 0.8 | 16.8 ± 0.4 | 19.8 ± 2.5
| **19.03 ± 0.19** | **0.84 ± 0.15** |
| warm | 32 | Δ | −3.0 ± 1.6 | −0.4 ± 0.6 | −2.4 ± 2.9 | +0.2 ± 2.5 |
−1.00 ± 0.33 | −0.93 ± 0.17 |

### `bench/nested-deps-app-router-many-pages`

| build | CPUs | arm | wall (s) | main sys CPU (s) | peak tree RSS (GB)
| main RSS when shutdown resolves (GB) |
|---|---|---|---|---|---|---|
| cold | 16 | canary | 34.6 ± 2.8 | 14.6 ± 2.1 | 15.76 ± 0.23 | 4.79 ±
0.13 |
| cold | 16 | stack | 36.4 ± 0.4 | 16.1 ± 0.9 | **11.37 ± 0.19** |
**0.51 ± 0.01** |
| cold | 16 | Δ | +1.8 ± 3.1 | +1.5 ± 2.9 | **−4.39 ± 0.16** | −4.29 ±
0.13 |
| cold | 32 | canary | 29.1 ± 2.0 | 51.4 ± 7.0 | 20.08 ± 0.18 | 5.09 ±
0.12 |
| cold | 32 | stack | 28.6 ± 2.7 | 51.3 ± 4.2 | **15.56 ± 0.10** |
**0.57 ± 0.02** |
| cold | 32 | Δ | −0.5 ± 0.9 | −0.1 ± 4.5 | **−4.52 ± 0.26** | −4.52 ±
0.11 |
| warm | 16 | Δ | −0.3 ± 1.6 | +0.4 ± 0.7 | −0.57 ± 0.06 | −0.65 ± 0.03
|
| warm | 32 | Δ | +0.2 ± 0.8 | +0.3 ± 0.5 | −0.75 ± 0.06 | −0.66 ± 0.08
|

- Peak tree RSS is lower in every cell, and wall Δ is within noise
everywhere.
- The only consistent cost is about +2 s main sys CPU at 16 CPUs (cold
and warm), from #101589's eager purging during shutdown.

### Per-PR attribution (separate interleaved run, same boot,
`bench/large-app-router` cold, n=3)

| CPUs | arm | peak tree RSS (GB) | main RSS when shutdown resolves (GB)
|
|---|---|---|---|
| 16 | canary | 25.42 ± 0.27 | 13.19 ± 0.37 |
| 16 | #101529 + this fix (without #101589) | 21.92 ± 0.18 | 1.13 ± 0.01
|
| 16 | #101589 only | 19.90 ± 0.26 | 6.46 ± 1.52 |
| 32 | canary | 32.54 ± 0.37 | 14.39 ± 0.34 |
| 32 | #101529 + this fix (without #101589) | 22.22 ± 1.04 | 1.33 ± 0.07
|
| 32 | #101589 only | 25.87 ± 0.49 | 7.66 ± 0.51 |

Peak tree RSS in this table is the 3-sample median. #101589 lowers the
peak while shutdown is running. #101529 plus this PR return the rest
when shutdown resolves.

### Note for work that starts static workers earlier (e.g. a worker fork
server)

Main-process RSS when `project.shutdown()` resolves is now ~1.0–1.2 GB
on `bench/large-app-router`, down from 13–14.5 GB on canary.

<!-- 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>
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.

2 participants