Repository navigation
Turbopack: return freed memory eagerly while shutting down the project - #101589
Conversation
Tests PassedCommit: 3fc4fae |
4138cf9 to
6faf08b
Compare
| 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, | ||
| }); |
There was a problem hiding this comment.
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
|
Thanks! State representation: done in 0a56696. The set/restore logic now lives in a reusable This PR without #101529: measured on canary f4ea7a7 (mimalloc v3.6.1). Setup:
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 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 |
## 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>
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>
0a56696 to
c558ea9
Compare
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>
…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>
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?
next buildspends 20–30 s inproject.shutdown(), mostly persisting the filesystem cache.Part of #100250. A worker fork-server PR (#101586) will be benchmarked on top of this one.
How?
turbo-tasks-malloc:TurboMalloc::enter_eager_purge_mode() -> EagerPurgeGuard.purge_delayoption is 0, so freed memory is returned right away. The previous value is restored when the last guard is dropped.MIMALLOC_PURGE_DELAY=-1), is left alone.custom_allocatoroff).OptionOverride(turbo-tasks-malloc/src/option_override.rs).enter()returns a sharedOptionOverrideGuardthat ends the override on drop. Its state isNonewhile no override is active. turbopack: return small free ranges to the OS after project shutdown #101623 reuses it forarena_purge_min_size. It usesmimalloc::sys::mi_option_purge_delayand the safemimalloc::option_get/option_setfrom the official mimalloc bindings.project_shutdown: enters the mode before the shutdown hooks andstop_and_wait(persistence), and ends it beforerelease_freed_memoryfrom Turbopack: release freed memory right after project shutdown #101529.purge_delayis 0, mimalloc skips purges that were scheduled before the mode started, even for a forced collect.collect(true)return them (mi_arenas_try_purgereturns early when the delay is<= 0).project_on_exit) is unchanged, sonext devnever enters the mode.Thread safety
mimalloc documents
mi_option_set/mi_option_getas not thread-safe. The option is a plainlongthat allocator threads read without synchronization while it's changed, so this is formally a data race. We accept it as benign in practice, andoption_override.rsdocuments it (including that options such asallow_thp, which also affect how memory is allocated, must not be overridden at runtime):long, read and written without tearing on supported targets. A concurrent reader sees either the old or the new delay.Benchmarks
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.taskset -c 0,2,…,30(one per physical core); 32 CPUs =0-31..nextremoved) 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./procevery 100 ms. "Shutdown span" isprojectShutdowncalled → resolved.Cold build
Warm build
Approximate process-tree CPU (main process + reaped and live descendants), from the last 3 iterations only:
Notes:
/procsampling 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.next devnext devon the same app at 32 CPUs, 3 interleaved runs per variant (cold.next):/s0/r1(ms)There is no measurable effect, as expected:
next devnever enters the mode.Tests
turbopack/crates/turbo-tasks-malloc/src/eager_purge.rshas a unit test for guard semantics: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_memorycargo clippy -p turbo-tasks-malloc -p next-napi-bindings --all-targetscargo check -p turbo-tasks-malloc --target wasm32-wasip1-threads