Luke Sandberg
90d · built 2026-07-24
90-day totals
- Commits
- 78
- Grow
- 10.1
- Maintenance
- 17.6
- Fixes
- 4.5
- Total ETV
- 32.2
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 72 %
- By Growth share
- Top 69 %
30-day trajectory
Last 30 days vs. the 30 days before. Up arrows on Growth and ETV mean improvement; up arrow on Fixes share means more time on fixes (worse).
↓-36.7 %
vs 30 prior
↑+53.7 pp
recent vs prior
↑+14.8 pp
recent vs prior
Daily performance
Daily ETV, stacked by Growth, Maintenance and Fixes.
Work-mix over time
Share of Growth / Maintenance / Fixes over a rolling 7-day window. Reads as 'where is effort flowing right now'.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 20 by ETV in the 90-day window.
- 3.4ETVProof of concept: task eviction after snapshot for turbo-tasks-backend (#91790) > **Note:** This is a **proof of concept** implementation. It is not yet ready for production use. ## Summary Implements memory eviction for the turbo-tasks engine. After a persistence snapshot completes, tasks that are safe to remove are evicted from in-memory storage and transparently restored from disk on next access. ### Eviction levels - **Full eviction**: Entire task removed from the in-memory map (restored from disk on access). Only possible when the task has no meaningful transient state (and other state is already on disk) - **DataAndMeta eviction**: Both data and meta categories cleared, but the task stays in the map to preserve transient state (e.g. `current_session_clean`, aggregated session-clean counts). - **DataOnly eviction**: Only data-category fields cleared; meta (graph structure, output, dirty state) stays in memory. - **MetaOnly eviction**: Only meta-category fields cleared; data stays in memory. Data and meta evictability are computed independently — if one category is modified but the other is clean, the clean category can still be dropped. Eviction is gated behind `BackendOptions::evict_after_snapshot` (off by default), and can be enabled in Next.js via the `TURBO_ENGINE_EVICT_AFTER_SNAPSHOT=1` env var for testing. ## Key changes - **Orthogonal eviction decision tree** (`storage_schema.rs`): Data and meta evictability are computed independently. Full eviction additionally requires no meaningful transient state (session-clean flags, aggregated session-clean counts). Replaces the previous sequential bail-out approach which was too aggressive on full eviction (losing transient session state on leaf tasks) and not aggressive enough on partial eviction (blocking all eviction when only one category was modified). - **`drop_partial()` codegen** (`task_storage_macro.rs`): New generated methods to drop data - **`restore_from_*()` codegen changes** (`task_storage_macro.rs`): New semantics for merging persistent data from the backend with transient data stored in memory. - **`task_cache` moved into `Storage`** (`storage.rs`): The `CachedTaskType → TaskId` deduplication map was previously a separate field on `TurboTasksBackendInner`. It is now owned by `Storage` so eviction can remove entries when a task is fully evicted. Because `task_cache` is a pure performance cache (entries are re-populated by `task_by_type()` on miss once the task type is persisted to backing storage), evicting entries is safe. After bulk eviction the map is shrunk when it is less than half full. - **Parallel shard eviction** (`storage.rs`): Eviction iterates all storage shards in parallel after snapshot, applying the appropriate eviction level per task. Each shard is shrunk after bulk eviction to reclaim slack capacity. - In principle this is O(N) work to scan, but because each pass drops >98% of tasks there isn't wasted work and the logic is fast, taking <100ms for even the largest applications. ## Design notes - **SessionDependent tasks**: SessionDependent tasks can still be evicted but if `current_session_clean` is set we prevent full eviction to avoid rechecking. Within a session the file-watchers are responsible for invalidations after setting `current_session_clean`. ## Known limitations (proof of concept) - No LRU or access-frequency tracking — all eligible tasks are evicted on every snapshot cycle - No memory pressure feedback — eviction runs on a timer, not in response to actual memory pressure - Only runs after snapshotting which tends to be a high point in memory - Future work will explore interleaving this logic with snapshotting to trim the peak <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · d81d5ab7 · 2026-05-10
- 2.2ETVturbo-tasks: task-storage memory wins (#93720) ## Summary Four small, independent changes that shrink `TaskStorage` and the data it owns: Recommend reviewing commit-by-commit 1. **`Arc<CachedTaskType>` → `triomphe::Arc<CachedTaskType>`.** `triomphe::Arc` is already a workspace dep used in `ReadRef` / `SharedReference`. `CachedTaskType` never appears in a `Weak<...>`, so we can drop the weak count and the CAS in `drop_slow`. Saves one `usize` per allocation. Migrated via a `CachedTaskTypeArc` newtype so the bincode `Encode`/`Decode` impls don't need to cross the orphan rule. 2. **Niche-encode `CellDependency`.** The `cell_dependencies` / `cell_dependents` sets used to hold `(CellRef, Option<u64>)` tuples — `Option<u64>` cost a full 16 B (8 B discriminant + 8 B value, aligned), making each element 32 B. A `CellDependency` enum with two variants (`All(CellRef)` / `Hash(CellRef, u64)`) lets the layout algorithm reuse the niche on `ValueTypeId` (`NonZero<u16>`) inside `CellRef.cell.type_id` for the variant tag. Element size drops 32 → 24 B; `LazyField` from 56 → 48 B. The same enum backs both forward and reverse edges — for `cell_dependents` we re-point `CellRef.task` at the dependent task. Added `CellDependency::into_parts()` and use it in `iter_cell_dependents` / `iter_cell_dependencies` hot loops so the discriminant is checked once instead of twice via back-to-back `cell_ref()` + `key()` calls. 3. **`TaskStorage::lazy: Vec<LazyField>` → `TinyVec<LazyField>`.** The lazy vec only ever holds ~25 elements (one per declared lazy field in the schema). Swapping `Vec`'s 24 B `(ptr, len, cap)` header for `(ptr, len: u8, cap: u8)` + 6 B padding gives 16 B. Drops `size_of::<TaskStorage>()` from 136 → 128 B. `TinyVec` is hand-rolled so I added a push/iter micro-benchmark to confirm it doesn't lose performance vs std `Vec`. Results below. 4. **Rightsize collections** → Explore the `AutoSet`/`AutoMap` types in storage_schema and ensure each one is maximally sized for its natural alignment. ## Benchmark results ### `next build` on a representative app (15 runs each, M4 Pro, `caffeinate -dimsu nice -n -20`) Fresh same-day baseline against branch: | metric | canary | branch | Δ | 95% CI | significant? | |---|---:|---:|---:|---|:---:| | wall time | 40.83s | 41.12s | +0.7% | [−1.07s, +1.64s] | no | | user time | 282.27s | 283.21s | +0.3% | [−1.02s, +2.89s] | no | | sys time | 69.38s | 71.26s | +2.7% | [−1.54s, +5.32s] | no | | **MaxRSS** | **12.47 GB** | **12.04 GB** | **−3.4%** | **[−0.48 GB, −0.38 GB]** | **yes** | **MaxRSS is the headline.** −0.43 GB on a 12.5 GB working set, with t=−17.86 (every branch run lower than every canary run, CV ≤ 0.6% on both sides). Wall / user / sys are all within noise — this PR is a memory win with no measurable timing impact. ### `TinyVec` vs `Vec` micro-bench (`turbo-tasks/benches/tiny_vec.rs`, 200 samples each) | n | Vec push | TinyVec push | Δ% | Vec iter | TinyVec iter | Δ% | |---:|---:|---:|---:|---:|---:|---:| | 0 | 1.31ns | 894ps | **−31.8%** | 598ps | 596ps | −0.4% | | 1 | 16.92ns | 14.75ns | **−12.9%** | 964ps | 952ps | −1.2% | | 4 | 17.93ns | 15.93ns | **−11.1%** | 1.49ns | 1.50ns | +0.5% | | 8 | 63.13ns | 45.24ns | **−28.3%** | 1.97ns | 1.96ns | −0.2% | | 16 | 97.35ns | 79.91ns | **−17.9%** | 3.16ns | 3.14ns | −0.5% | | 24 | 137.41ns | 119.88ns | **−12.8%** | 4.30ns | 4.30ns | +0.0% | TinyVec push is 11–32% faster than Vec push across all realistic sizes; iter is identical. Run with `cargo bench -p turbo-tasks --bench tiny_vec`. ### `task_overhead/turbo` Criterion bench (M4 Pro, `--sample-size 200`) | variant | dur | canary | branch | Δ | significant? | |---|---:|---:|---:|---:|:---:| | turbo-uncached | 1µs | 9.77 µs | 9.68 µs | −1.0% | yes | | turbo-uncached | 1000µs | 1.01 ms | 1.01 ms | −0.1% | yes | | turbo-cached-same-keys | 1µs | 198.6 ns | 191.9 ns | −3.4% | yes | | turbo-cached-same-keys | 100µs | 226.5 ns | 208.1 ns | −8.1% | yes | | turbo-cached-different-keys | 1µs | 233.8 ns | 224.1 ns | −4.2% | yes | | turbo-cached-different-keys | 100µs | 305.3 ns | 246.9 ns | −19.1% | yes | | turbo-uncached-parallel | 10µs | 1.63 µs | 1.54 µs | −5.8% | yes | | turbo-uncached-parallel | 100µs | 8.41 µs | 7.88 µs | −6.3% | yes | <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 05553796 · 2026-05-20
- 1.9ETV[turbopack] Optimize the implementation of AutoMap/AutoSet (#95694) Optimize the implementation of AutoMap/AutoSet. Previously the `List` variant was backed by a `SmallVec` in inline mode, which needs at least 24 bytes of header (`len`, `cap`, `ptr` — all `usize`) even though the list never holds more than 32 elements. This replaces it with the `TinyVec` struct from turbo-tasks-backend which is now enhanced with support for an `inline` array. The length is a `NonZeroU8`, which reserves a niche the `AutoMap` enum folds its discriminant into — dropping `AutoMap`'s minimum size to 16 bytes. This in turn shrinks `TaskStorage` and the `LazyField` enum. Some inline structs in TaskStorage are increased in size so we keep the 128 byte footprintgithub.com-vercel-next.js · aaf2fe3d · 2026-07-14
- 1.9ETVRemove ineffective turbo-tasks (#91341) ## Remove ineffective turbo-tasks Identifies and removes turbo-tasks functions where the task overhead exceeds the value they provide. Each turbo-task carries ~4-6μs execution overhead per miss and ~200-500ns per cache hit, plus allocations and bookkeeping. ### What? Removes 22 `#[turbo_tasks::function]` implementations across resolve plugins, chunk items, and resolve-result helpers — converting them to plain methods or inlining their work. Changes fall into a few buckets: - **ResolvePlugin condition handling** (`AfterResolvePluginCondition::matches`, `BeforeResolvePluginCondition::matches`, `after_resolve_condition`, `before_resolve_condition`): conditions now store the resolved `Glob` as a `ReadRef<Glob>` on the plugin struct at construction, so `matches` is a pure sync function and the per-plugin `*_resolve_condition` getters are trivial field reads (no longer turbo-tasks). The `after_resolve` / `before_resolve` hooks themselves stay as `#[turbo_tasks::function]` — they synthesize virtual sources/modules and need memoization on `(self, lookup_path, reference_type, request)` to avoid distinct cells producing duplicate module-graph idents. - The basic theory here is that the right level of caching is at `resolve` and at the hook bodies themselves, not the conditions or condition getters. - `AfterResolvePluginCondition` and `BeforeResolvePluginCondition` are marked `serialization = "none"` because `ReadRef` cannot be persisted; plugin construction is cheap enough to re-derive on restore. - **ChunkItem trait methods** (`chunking_context`, `ty`, `content_with_async_module_info`): returned constants or simple field reads, zero cache hits and no `.await` calls (no invalidation value). - **ResolveResult / ModuleResolveResult helpers** (`primary_modules`, `first_module`, `first_source`, `primary_sources`, `is_unresolvable`, `primary_output_assets`): simple iterators over already-resolved data; converted to plain methods. Added a `Duplicate(usize)` variant to `ModuleResolveResultItem` to handle dedup at construction time instead of in a separate task. - The basic idea here is that it is reasonable to consume `ResolveResult/ModuleResolveResult` monolithically, and we get little to no benefit from fine grained access. e.g. `is_unresolved()` in theory that is a valuable turbotask, but since it rarely changes but generally if we change how we resolve an import then we have to regenerate code, so saving a few boolean conditions is unlikely to be very valuable. - Misc: `EcmascriptModuleAsset::analyze`, `is_types_resolving_enabled`, `next_server::resolve::condition`. ### Impact (vercel-site build, dev first-compile) | Metric | Before | After | Δ | |---|---:|---:|---:| | Total cache hits | 30,885,827 | 29,201,314 | −1,684,513 | | Total cache misses | 6,473,123 | 5,953,626 | **−519,497** | | Overall hit rate | 82.67% | 83.06% | +0.39 pp | | Registered task functions | 1,294 | 1,272 | −22 | The 22 removed tasks were collectively responsible for ~519K misses per build — each miss previously paying the full execution overhead. Most of the work from `EcmascriptModuleAsset::analyze` naturally migrated into `analyze_ecmascript_module` (the task it was wrapping; +129K hits there). ### On-disk cache size (persistent caching) Each removed task also stops allocating cache cells on disk. Measured on the same vercel-site build with `.next/cache/turbopack` (persistent cache enabled): | | Size | |---|---:| | canary | 2.56 GiB | | this branch | 2.46 GiB | | **saved** | **~100 MiB (−3.81%)** | ### Build-time wall clock and peak memory Ran `pnpm next build --experimental-build-mode=compile` 5 times on each branch **Peak RSS — clear reduction:** | | canary | branch | Δ | |---|---:|---:|---:| | min | 19.18 GiB | 18.94 GiB | | | **median** | **19.22 GiB** | **19.01 GiB** | **−217 MiB (−1.10%)** | | mean | 19.21 GiB | 19.02 GiB | −199 MiB (−1.01%) | | max | 19.23 GiB | 19.13 GiB | | Every branch run has lower RSS than every canary run — the distributions don't overlap. Welch's t = −6.03. **Wall time — no measurable change:** | | canary | branch | Δ | |---|---:|---:|---:| | min | 62.03s | 60.78s | | | **median** | **62.61s** | **62.65s** | **+0.04s (+0.06%)** | | mean | 62.83s | 63.80s | +0.96s (+1.53%) | | max | 64.25s | 68.23s | | | stddev | 0.84s | 3.42s | | Median is flat. The mean difference is within noise (Welch's t = +0.61, n = 5). Branch run-to-run variance is higher — one 68.23s outlier pulls the mean up — so this is neither a regression nor a measurable speedup at this sample size. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · f0c1ffc4 · 2026-05-08
- 1.9ETV[turbopack] Enable Effects to be evicted (#94173) ### What? Enable Effects to be evicted. Currently `EffectInstance` transitively holds a `ReadRef<PersistentFileContent>` which means eviction cannot not free the memory for the outputs. Because the `ReadRefs` are captured by the task producing the `Effect` we cannot just drop them (otherwise we could need to somehow re-execute the write to recover them). So instead we need a new 'effect processing phase' Here is our new state machine: * Phase 1: `emit` effects. - At this point we capture the content as a `ResolvedVc` and the hash as a value. - In principle this means the hash and content can get out of sync, but only within an eventual consistency race which callers should be handling. * Phase 2: `take_effects`. - This will call `capture()` on all the effects which will conditionally read the content cell if the hash doesn't match the `EffectStateStorage` * Phase 3: `Effects::apply`. - This will apply the effects for all effects whose hashes don't match storage. - At this point we may discover that a hash in storage changed between `capture` and `apply` which will trigger a special `Retry` error and `invalidate` `Phase 2`. To facilitate the `Retry` logic a `read_strongly_consistent_and_apply_effects` helper function handles it. Which allows for a simple circuit breaker and some verbose warnings/errors when we retry too many times. Specifically: - Split the `Effect` trait into `Effect` (emit-side, returns a `Captured` payload) and `CapturedEffect` (apply-side). ` - Switch `Effects` to `cell = "new"`. Every producer re-execution allocates a fresh cell value; the prior cell (and its captured payload) drops on overwrite, cascade-releasing upstream `ReadRef<PersistedFileContent>` strong counts. The captured payload also drops naturally when the cell is evicted. - Push the per-key state machine (`Unapplied` / `InProgress` / `Applied { value_hash, result }`, `EventGuard` panic recovery, `Event` listener coordination) down into `EffectStateStorage::run_apply`. `Effects::apply` becomes a thin aggregator over `dyn_apply()` calls. - New `ApplyOutcome<E> { Failed(E), Retry }`. `CapturedEffect::apply` returns this; `Retry` signals "capture elided content but storage diverged before apply" — `Effects::apply` collects across the batch, fires the producer's invalidator once, and surfaces `EffectsError::Retry`. - New `EffectStateStorage::matches_applied(key, hash)`. `WriteEffect::capture` / `WriteLinkEffect::capture` consult this and skip the `ReadRef<PersistedFileContent>` / `ReadRef<LinkContent>` materialization when storage already holds `Applied { matching, Ok(()) }` — avoiding the disk-read + decompression hit on producer re-runs that don't actually change output. - 10 integration tests in `turbopack/crates/turbo-tasks-backend/tests/effects.rs` cover the new lifecycle: duplicate apply, sibling stomp re-apply, repeated apply with unchanged state dedupe, `cell = "new"` producing distinct cells per producer run, capture-skip on storage match, and the capture-skip-then-stomp race that exercises the `Retry` path. ### Why? A memory audit showed `EffectInstance` cells accumulating the majority of the ram in an eviction session #### Capture-time storage check (the perf story) `Effect::capture` may consult `EffectStateStorage::matches_applied` and elide the content materialization when storage already records the target hash. `CapturedWriteEffect.content` becomes `Option<ReadRef<PersistedFileContent>>` — `None` means "capture observed `Applied { matching }`; no `body` to pass to `run_apply`". The apply-side state machine then either dedup-hits (storage still matches → cached `Ok`) or enters the no-body branch and returns `ApplyOutcome::Retry`. Reading shared mutable `EffectStateStorage` from inside a turbo-tasks task is normally suspect (the state isn't a tracked input). It is sound here because the apply-time re-check fires the producer's invalidator on mismatch, turning the otherwise-untracked read into an explicitly-tracked one via the `Retry` pathway. The producer reruns, `capture` sees the new storage state, materializes content, and the next `apply` succeeds. #### `ApplyOutcome::Retry` and the invalidator `Effects::apply` collects `Retry` signals across the parallel batch via `AtomicBool`. `Failed(e)` errors fail-fast. After the batch, if any `Retry` fired (and no `Failed`), the producer's invalidator runs once and `EffectsError::Retry` propagates. Callers re-read the operation; the producer reruns; fresh `capture` either materializes content (storage diverged) or dedup-hits cleanly. #### `turbo-tasks-fs` `WriteEffect::capture` / `WriteLinkEffect::capture` call `matches_applied` first and conditionally `.await?` the content `ResolvedVc`. `CapturedWriteEffect::apply` / `CapturedWriteLinkEffect::apply` dispatch through `run_apply(key, hash, body)`, where `body` is `Some(closure)` iff content is `Some`. `apply_inner` takes the unwrapped `&ReadRef<…>` as a parameter so the option-unwrap is at the dispatch boundary (type-enforced). ### Risk: infinite retry loop **Failure mode.** Two sibling producers A and B contend on the same key with different hashes (e.g. two routes that both want to write `chunks/foo.js` with different content). Each `Retry` from A's apply invalidates A's producer; each `Retry` from B's apply invalidates B's producer. In the worst case, A and B perpetually re-capture, race to apply, stomp each other's storage state, and re-trigger Retry — invalidating themselves on each cycle. **Why we believe it terminates in practice.** A `Retry` only fires when (a) capture observed `Applied { matching }` and elided content, and (b) by apply time storage was stomped to a different hash. Each `Retry` invalidates only its own producer (no cross-producer invalidation), so without external input churn the system reaches a fixed point — whichever producer applies last wins, and subsequent re-runs of the loser's producer materialize content (because storage no longer matches) and apply cleanly. The pathological case requires a real, ongoing race fundamentally triggered by external invalidations (file edits). <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 804b9a0b · 2026-06-07
- 1.7ETVTurbopack: fix error reporting with crashing webpack loaders (#93926) ### What? When a Turbopack webpack-loader subprocess crashes (e.g. a loader calls `process.exit()`, a native fatal error, or the IPC socket otherwise closes mid-message), the error users see today is: ``` - Execution of <WebpackLoadersProcessedAsset as Asset>::content failed - Execution of WebpackLoadersProcessedAsset::process failed - Execution of evaluate_webpack_loader failed - failed to receive message - reading packet length - unexpected end of file ``` After this PR, the same crash produces: ``` ⨯ ./data/crash.data Error evaluating Node.js code Error: Node.js subprocess crashed while evaluating loaders [/path/to/loaders/crash-loader.js]: failed to receive message Caused by: - Node.js process exited with exit status: 7 - reading packet length - unexpected end of file Debug info: - failed to receive message - Node.js process exited with exit status: 7 Recent process stderr: <whatever the loader wrote to stderr before exiting> - reading packet length - unexpected end of file ``` ### Why? The original message gave no actionable information: no exit code, no captured stdout/stderr, no indication of which loader was running. It also looked like an internal turbopack bug rather than a user-fixable error, and a transient pool failure could cascade into an unrelated "issue formatter crashed while reading the source for a code frame" failure on the way out. ### How? Four orthogonal fixes, plus a regression test: 1. **Capture stdout/stderr on subprocess crash.** `OutputStreamHandler` now keeps a bounded ring buffer (last 100 lines per stream) shared with the owning `NodeJsPoolProcess`. When `NodeJsPoolProcess::recv` fails, the buffers and the child's exit status are attached to the error via `anyhow::Error::context`. 2. **Recover from subprocess crash in `pull_operation`.** Instead of propagating the recv error up through `evaluate_webpack_loader` → `process()` → `Asset::content` (the cascade above), `pull_operation` catches it, synthesizes a `StructuredError` via `evaluate_context.emit_error(...)`, disables process reuse, and returns `Ok(None)`. This mirrors the existing in-band loader-error path, so the asset's existing `FileContent::NotFound` degradation kicks in naturally — `Asset::content` never errors. 3. **Include the loader chain in the error message and issue detail.** `WebpackLoaderContext` gained a `loader_names: Vec<RcStr>` field. A new optional `EvaluateContext::crash_context_prefix()` trait method lets webpack-loader evaluations describe what was being evaluated (\"loaders [a, b, c]\") in the synthesized crash message. `EvaluationIssue` also gained an optional `detail` field for the same chain, surfacing it in `--log-detail` output. PostCSS evaluations are labelled \"postcss\". 4. **Crash-proof the issue formatter.** `PlainSource::from_source` and `IssueSource::into_plain` previously propagated errors from `asset.content()` with `?`. They now degrade to `FileContent::NotFound` (and `range = None`) on read failure, so a future regression in some other code path can never cause the issue reporter itself to crash on top of whatever the user was debugging. ### Tests - Added `test/e2e/app-dir/webpack-loader-errors/loaders/crash-loader.js`: a loader that writes a marker to stderr and calls `process.exit(7)`. - Added an e2e test that fetches `/crash` and asserts the marker, the absence of the internal cascade, the loader name, and the resource name are all present in the CLI output. - All 11 tests in `webpack-loader-errors.test.ts` pass; the 5 Rust `turbopack-node` pool tests still pass. Some snapshot/golden tests for error formatting may need updating in CI since `EvaluationIssue` now emits a non-empty `detail`. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · d46516ce · 2026-05-20
- 1.6ETVTurbopack: simplify asset ident constructors (#93213) ### What? Removes the per-method turbo-task constructors on `AssetIdent` (`from_path`, `with_query`, `with_fragment`, `with_modifier`, `with_part`, `with_path`, `with_layer`, `with_content_type`, `with_asset`, `rename_as`, and `path`). Each of those was its own cached task that returned a small projection or a one-field-changed copy. They are now plain Rust builder methods on the owned value, with a single `into_vc()` at the end of the chain that goes through the existing cached `new_inner` constructor. Call sites that previously chained `Vc` methods now look like: ```rust module .ident() .owned() .await? .with_modifier(rcstr!("async loader")) .into_vc() ``` ### Why? These constructors were tiny "projection" turbo-tasks that paid the cost of a task lookup, cell allocation, and dependency tracking but whose cache layer didn't meaningfully prevent recomputation. The trade-off is invalidation semantics: - **Before:** a caller doing `module.ident().path()` depended on the cached `path()` projection. If the source `AssetIdent` changed but its `.path` field was unchanged (e.g. a new modifier was added), `path()` re-ran, returned the same `FileSystemPath` cell, and the caller did not re-run. - **After:** the same caller does `module.ident().await?.path` and depends directly on the `AssetIdent` cell. Any change to the ident (modifier, query, layer, …) invalidates the caller, even if the path is unchanged. In practice this is rarely a real loss: when an ident changes, the `Module` typically changes too, and the dependent task was going to re-run anyway. `new_inner` already deduplicates structurally-equal idents, so the wrappers were paying overhead per call without buying meaningful invalidation isolation. Measured on a `vercel-site` build via `NEXT_TURBOPACK_TASK_STATISTICS` and `turbopack/scripts/analyze_cache_effectiveness.py`: | Task | canary (hits / misses) | this branch | | --------------------------------- | ---------------------- | ----------- | | `AssetIdent::path` | 778,273 / 98,018 | removed | | `AssetIdent::with_modifier` | 27,895 / 22,801 | removed | | `AssetIdent::from_path` | 2,954 / 29,650 | removed | | `AssetIdent::with_part` | 2 / 5,440 | removed | | `AssetIdent::with_layer` | 7 / 4,356 | removed | | `AssetIdent::rename_as` | 4,969 / 2,269 | removed | | `AssetIdent::with_query` | 0 / 521 | removed | | `AssetIdent::with_content_type` | 0 / 79 | removed | | `AssetIdent::new_inner` | 628 / 129,777 | 29,213 / 120,650 | Aggregate over the whole build: - Total cached tasks: 1,300 → 1,292 - Total task invocations: 39,361,186 → 38,208,036 (~1.15M fewer lookups) - Total cache misses: 6,812,198 → 6,639,937 (~172k fewer) - Overall hit rate: 82.7% → 82.6% (essentially unchanged) `new_inner` absorbs the construction work that used to be split across the wrappers. Four upstream tasks gained +519 cache hits each (`EsmAssetReference::resolve_reference`, `ReferencedAsset::from_resolve_result`, `NextServerUtilityModule::ident`, `NodeJsChunkingContext::chunk_item_id_strategy`); no task gained any new misses. ### How? - `AssetIdent::from_path` and the `with_*` methods are now plain `&mut self`/`self`-by-value builder methods on the struct itself, not `#[turbo_tasks::function]`s. - A new `AssetIdent::into_vc(self)` finalizes the builder by going through the still-cached `new_inner`. - `AssetIdent::path()` is removed; callers use `.path` on an owned `AssetIdent`. - All call sites across `turbopack-*` and `next-*` crates are updated. Most go from `ident.with_modifier(m)` (returning `Vc`) to `ident.owned().await?.with_modifier(m).into_vc()`. - A follow-up commit removes a few `.clone()`s introduced in the conversion that aren't needed once lifetimes are bound to a local. ### Follow-ups (out of scope) While migrating call sites, two pre-existing entry builders surfaced as candidates for cleanup. Not addressed here, but worth noting: - `get_app_page_entry` (`crates/next-core/src/next_app/app_page_entry.rs`) replaces the *content* of the source returned by `load_next_js_template` (prefixing imports onto `result.build()`) but reuses the template's `ident` with a `?page=...` query suffix as a disambiguator. The new `VirtualSource` ends up with content from one place and an ident chain pointing at another. A cleaner shape would be to mint a fresh ident from the page path, since the caller already knows what it's building. - `create_page_ssr_entry_module` (`crates/next-pages/page_entry.rs`) has the same shape on the instrumentation-conflict branch: it appends `export const register = hoist(...)` to the template content and constructs a `VirtualSource` with the original `source.ident()` unchanged. Lower-frequency than the app-page case (fires at most once per build), but the ident still misrepresents the constructed content. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 0f38c522 · 2026-05-06
- 1.3ETV[turbopack] Optimize TaskInput implementations (#94202) # What Replace the `derive` macros for TaskInput with a proc macro `[turbo_tasks::task_input]` (and analogous support inside of the `turbo_tasks::value` macro. Introduce a hand written `CloneResolved` future type for TaskInput implementations that are always resolved. # Why This allows us to take advantage of the `NonLocalValue` marker trait. If a type is `NonLocalValue` then `TaskInput` can have trivial `is_resolved` and `resolve_input` implementations. This applies to the vast majority of TaskInputs but the current `derive` implementation always produces a recursive `async` `resolve_input` implementation but it is rarely needed and rustc/llvm do a bad job of optimizing it. ## Code-size impact `next-swc.darwin-arm64.node` — release profile, `pnpm swc-build-native --release`, macOS aarch64. | Metric | canary | this branch | Δ bytes | Δ % | | --- | ---: | ---: | ---: | ---: | | Raw `.node` | 124,494,592 | 124,217,280 | −277,312 | −0.22% | | Stripped (`strip -x`) | 84,294,000 | 84,013,136 | −280,864 | −0.33% | | Stripped + gzip -9 | 29,123,538 | 28,898,920 | −224,618 | −0.77% | ## Performance A similar measurement of performance was neutral | Metric | canary | temporary | Δ | % | |---|---|---|---|---| | wall time (s) | 42.659 ± 1.851 | 42.501 ± 2.028 | −0.158 | −0.4% | | user time (s) | 294.322 ± 1.834 | 293.207 ± 2.070 | −1.115 | −0.4% | | sys time (s) | 74.158 ± 4.197 | 73.543 ± 5.725 | −0.615 | −0.8% | | MaxRSS (MB) | 13650.1 ± 66.0 | 13610.1 ± 57.5 | −40.0 | −0.3% | All deltas are smaller than one standard deviation — statistically indistinguishable from noise on this benchmark, as expected for a small optimization.github.com-vercel-next.js · dc856d6c · 2026-06-03
- 1.2ETVfix(watch): recover from slow initial hash instead of timing out (#13159) ## Problem `turbo watch` fails at startup with: ``` × Timed out waiting for the file watcher to become ready. Try running `turbo daemon clean` and retrying. ``` when a large **untracked** file lives in the repo. On macOS the time goes to git-hashing that file: `git status` lists the untracked file and `hash_objects` reads its full contents in the startup hash loop, blowing past the fixed 10s readiness deadline. The error was fatal, and the `turbo daemon clean` advice is misleading since watch mode runs the watcher in-process (no daemon). ## Fix - **Recover instead of failing.** Replace the one-shot 10s wait with a bounded retry loop (10s attempts up to a configurable `TURBO_WATCH_STARTUP_TIMEOUT`, default 120s). Warn on the first stall; only fail after the cap. - **Name the culprit.** A new `SlowestFiles` structure in `turborepo-scm` tracks the slowest-to-hash files **by time, including in-flight ones** (a file still being hashed is the likely cause of a hang). It's recorded inside the `hash_objects` rayon loop and surfaced via `HashWatcher::slowest_files()`. The startup warning/error now names the real file (project-relative, one per line) instead of guessing. - **Align the timeouts.** The package-changes subscriber's inner readiness wait was a hardcoded 5s — shorter than the outer cap, so it could abort before the outer loop reported why. Both now derive from a shared `startup_timeout_secs`. - **Fix the message.** Drop the `turbo daemon clean` advice. ## Real-repo verification Built and run against a real project with a large untracked Turbopack trace artifact: ``` • turbo 2.10.1-canary.1 • Packages in scope: frame, v0chat, web • Running dev in 3 packages • Remote caching disabled WARNING File watcher still initializing after 10s, likely a large file is slowing the initial hash. Slowest files to hash: chat/.next-profiles/trace-turbopack.bin (8.3s, still hashing) web/.next-profiles/trace-turbopack.bin (0.2s) frame/.next-profiles/trace-turbopack.bin (0.2s) Retrying... ``` ...and then startup **succeeded** instead of dying at 10s. The warning correctly pinpoints the blocking file (`trace-turbopack.bin`, still hashing at 8.3s) by hashing time — surfaced precisely because the recorder tracks live entries, not just completed ones. To force the fatal-after-cap path for testing: `TURBO_WATCH_STARTUP_TIMEOUT=1`. ## Tests - `SlowestFiles` unit tests (in-flight ordering, top-N bound, live-before-completed). - `turborepo-filewatch`: `test_large_file_recorded_as_slowest` — drops an 8 MiB **untracked** file into a fixture package, hashes it, asserts it appears in `slowest_files()`. (Committed files are read from the git tree and never go through `hash_objects`, so the file must be untracked — same as the real-world trigger.) - `slowest_files_hint` formatting test in watch.rs (one-per-line, in-flight flagging). - Full suites green: scm, filewatch, package_changes_watcher, watch; `clippy --workspace` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>github.com-vercel-turborepo · 05e26cb2 · 2026-07-02
- 1.1ETVturbo-tasks-backend: fix snapshot coordination races + extract SnapshotCoordinator (#93416) Hold a lock while persisting so two snapshots cannot execute concurrently * Currently if `stop` is called while an idle snapshot is running, then snapshotting can race with itself, this can corrupt the use of the `in_progress_operations` parameter since two threads will `fetch_or` with it and wait for the bit to be cleared Abort the process if `panic!` occurs during task spawning * Currently if `try_start_task_execution` panics it ends up hanging a task which can deadlock the process, in this case we have no better option than to just log and abort. * I considered strategies that would 'poison' the task or possibly all of turbo-tasks and this is attractive but i believe fundamentally unsafe, the most likely cause of these panics is something wrong with state tracking in the backend, so exiting is all we can dogithub.com-vercel-next.js · 2e1e5958 · 2026-05-04
- 0.8ETV[turbopack-trace-server] optimize loading (#93264) Land a few optimizations to the trace server * Change `SpanEvent` so it is 32 bytes instead of 40 bytes by triggering a `niche` optimization * Change `args` and `events` to be a `smallvec` with inline size 1 * for `args` it is size <=1 ~31% of the time * for `events` it is size <=1 69% of the time * Compute min/max timestamps in a single pass instead of 2 when inserting into the selftimetree * Bundle dynamically computed 'total' fields behind a single OnceLock * saves 40 bytes per span due to `Oncelock` overheads * Inline SpanTimeData and SpanNames into Span * We get little benefit from deferring the allocations and by inlining we save time and improve memory locality. * post load SpanTimeData is allocated for 94% of spans, but after loading `trace.nextjs.org` it is 100% * post load SpanNames is allocated for 0% of spans, but after loading it is 96.2% of spans * Remove the `inner` `OnceLocks` from `SpanNames` we can just allocate these all together Measuring with one 10gb trace file I see loading times progress from 75.7s (33G of ram) to 60.5s (19.5G of ram). With loading times hitting >200mb/s occasionallygithub.com-vercel-next.js · 8dee7acb · 2026-04-28
- 0.7ETVswitch our test benchmark runs to release-with-assertions (#94538) In ci when testing our benchmarks use our test profile `release-with-assertions` This avoids an expensive `lto` step and ensures that our tests run with debug asserts. I noticed that our workflow for 'test cargo benches' was very slow and a lot of that time was the build presumably due to lto overheads across so many benchmark binaries. Compare: * cargo benches for this pr: https://github.com/vercel/next.js/runs/79974366863?pr=94538 - build=4m42s - test=1m46s * cargo benches before this pr: https://github.com/vercel/next.js/actions/runs/27097792020/job/79973082715#logs - build=8m23s - test=6m56s Of course doing this revealed a few things * a birthday paradox panic in one of the persistence tests * some 'top level read' errors to address in the benchmarks and in the turbopack-cli server used for some benchmarks. And finally, that the persistence tests are just extremely slow in test mode due to their initialization overhead (populating >1GB dbs), so that harness is rewritten to remove some variants and also to reduce max sizes in test modegithub.com-vercel-next.js · 723c0620 · 2026-06-17
- 0.6ETV[turbopack] Don't evict when there is little memory to save (#95213) ### What? Adds a new `'auto'` mode to the experimental `turbopackMemoryEviction` config option and makes it the default. In `'auto'` mode, Turbopack only evicts in-memory cache after a snapshot once enough memory has been allocated since the last eviction to make the work worthwhile. The option now accepts three values: - `false`: never evict. - `'auto'` (new default): evict after a snapshot only once a memory threshold has been crossed. - `'full'`: evict all evictable data after every snapshot (the previous behavior). ### Why? When the persistent (FileSystem) cache is enabled, Turbopack snapshots its in-memory state to disk and can then evict those in-memory copies to reclaim memory, reloading them from disk on demand. Previously, with eviction enabled (`'full'`), we evicted after *every* snapshot. This is too aggressive: tasks get restored from disk and then immediately evicted again, cycle after cycle, wasting work for little memory benefit. `'auto'` mirrors the existing persistence-threshold model (we already skip a snapshot when too little compilation time has accumulated to justify its cost). Here the proxy is memory instead of time: it isn't worth paying the restore-then-re-evict churn to reclaim a small amount of memory. This was motivated by an example in v0.app where the client would poll the server every 5 seconds leading to a pathological behavior * client poll -> * next.js ensurePage -> * turbopack writeEndpointToDisk * recompute and restore settings for that endpoint (3-10ms of io work) * realize there is nothing to do * respond to client * 2 seconds later..... persist and evict everything saving 0.5M of ram (100ms of work! writes out 2 SSTs and then compacts them!) * 3 seconds after that restart the loop This is silly, #95137 will prevent the persistence loop from occurring , this PR will also just skip the 'restore and recompute' work using a similar strategy. ### How? We can't measure exactly how much memory a sweep would reclaim, so we use `TurboMalloc::memory_usage()` (process-global net live bytes) as a proxy. In `'auto'` mode an eviction sweep runs only once the net bytes allocated since the last eviction exceed a threshold (default 128 MiB, overridable via `TURBO_ENGINE_EVICT_MIN_BYTES`). The first eviction after startup always runs. The threshold scales down under OS memory pressure (`TurboMalloc::memory_pressure()`) so we evict more eagerly when memory is tight. **Backend (`turbo-tasks-backend`):** - `BackendOptions.evict_after_snapshot: bool` → `eviction_mode: EvictionMode` (`Off` / `Full` / `Auto`). - New `EvictionControl` type owns the policy: the mode plus the threshold bookkeeping. The background snapshot loop calls `should_evict(snapshot_had_new_data)` once per cycle and `record_eviction()` after a sweep, so the loop no longer branches on the mode. `'full'` and `false` behave exactly as before. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 4173ca26 · 2026-07-08
- 0.6ETV[turbopack] fix feature usage telemetry (#93100) ## Report Turbopack feature-usage telemetry Turbopack never reported `NEXT_BUILD_FEATURE_USAGE` telemetry for production builds. This PR wires it up and fixes a correctness bug in how the counts were computed, then cleans up the API surface that carried them across the napi boundary. ### Changes - **JS**: `turbopackBuild()` now records `EVENT_BUILD_FEATURE_USAGE` events after `writeAllEntrypointsToDisk` via a new `eventBuildFeatureUsageFromTurbopackDiagnostics` helper. Dev is out of scope — webpack's `TelemetryPlugin` is `!dev && isClient` too. - **Rust**: aligned feature names with the JS `EventBuildFeatureUsage['featureName']` union — SWC triple is now `swc/target/<triple>`; dropped `persistentCaching` (redundant with `turbopackFileSystemCache`) and `turbotrace: false` (hardcoded). ### Correctness fix: count unique importers, not resolves Previously feature-usage counts for module imports (`next/image`, `next/font/google`, …) were computed from a `BeforeResolvePlugin` that emitted one event per resolve. Turbopack caches resolves, so the emission fired at most **once per unique request** — the count was effectively `1` for every feature that was imported anywhere. Webpack's equivalent counts unique importing modules via `moduleGraph.getIncomingConnections(module).size`. This PR replaces the resolve-plugin emission with a single whole-app module-graph traversal on `Project`. For each tracked feature, we accumulate the set of unique parent modules of each matching node (mirroring webpack's "unique origin modules" semantics). Fonts are matched against their synthesized `/target.css?…` virtual modules produced by the SWC font-loader transform — matching webpack's `FEATURE_MODULE_REGEXP_MAP` approach. Paths are matched via `phf_map!` tables in `next_telemetry.rs`. ### Incidental simplifications While in here, the `Diagnostic` collectibles subsystem got right-sized and then removed entirely, since feature usage was its only consumer: - `Project::project_feature_usage()` returns a structured `Vc<ProjectFeatureUsageSummary>` instead of emitting diagnostics. Surfaced to JS as a dedicated `project.featureUsage(): Promise<BuildFeatureUsage[]>` napi method, called once at build's end. - `TurbopackResult<T>` loses its `diagnostics: BuildFeatureUsage[]` field — it's now just `{ result, issues }`. Every napi result type and ~10 construction sites are correspondingly simpler. - Deleted `turbopack_core::diagnostics` entirely (`Diagnostic` trait, `DiagnosticExt`, `DiagnosticContextExt`, `CapturedDiagnostics`, `PlainBuildFeatureUsage`). Deleted `FeatureUsageTelemetry`, `ModuleFeatureReportResolvePlugin`, `get_diagnostics()` aggregation, the `feature_usage`/`diagnostics` fields on `AllWrittenEntrypointsWithIssues`/`OperationResult`/`EntrypointsWithIssues`/`WrittenEndpointWithIssues`/`HmrUpdateWithIssues`/`HmrChunkNamesWithIssues`/`EndpointIssuesAndDiags`/`WriteAnalyzeResult`, and the defensive `drop_collectibles::<Box<dyn Diagnostic>>()` scrub in `entrypoints_without_collectibles_operation`. Feature-usage telemetry now flows as a plain return value end-to-end: `Project::project_feature_usage()` → napi `projectFeatureUsage()` → JS `project.featureUsage()` → `telemetry.record()`. No collectibles, no peeking, no emission-as-side-effect. ### Tests Un-skipped four previously webpack-only integration tests in `test/integration/telemetry/test/config.test.ts`: `image/script/dynamic`, `next/legacy/image`, `transpilePackages`, and middleware options. All pass under Turbopack. The remaining three skipped tests (`swc` flags, `@vercel/og`, `useCache`) cover features Turbopack doesn't emit yet — left skipped with TODOs. Added unit test for the helper at `packages/next/src/telemetry/events/build.test.ts`. Updated the Turbopack `next-rs-api` snapshot to reflect the new diagnostic shape. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · e8f8f498 · 2026-05-11
- 0.6ETVSimplify turbo-tasks-backend: collapse single-impl traits and remove redundant Arc layers (#93983) ### What? Simplifies `turbo-tasks-backend` by removing abstraction layers that each had a single implementation, plus several redundant `Arc` indirections that were no longer load-bearing. **Traits removed / collapsed (each had exactly one implementor):** - **`KeyValueDatabase`** — folded into inherent methods on its only impl, `TurboKeyValueDatabase`. - **`BackingStorage` + `BackingStorageSealed`** — folded into inherent methods on the renamed concrete type `TurboBackingStorage` (was the `KeyValueDatabaseBackingStorage<T>` generic + a type alias). - **`ConcurrentWriteBatch`** — folded into inherent methods on `TurboWriteBatch` (it existed to support an old backend implementation that's gone). - The **`B: BackingStorage` generic parameter** is dropped from `TurboTasksBackend`, `TurboTasksBackendInner`, `ExecuteContextImpl`, and `ChildExecuteContextImpl`. Every instantiation in the workspace already resolved to one concrete type. The only remaining trait in the crate's public role is `Backend` (defined in `turbo-tasks`), which stays — removing it would invert the `turbo-tasks` → backend dependency. **`Arc` indirections removed:** - `TurboKeyValueDatabase.db: Arc<TurboPersistence>` → `TurboPersistence` (the write batch only borrowed it; the clone in `new` was gratuitous). - The `self: &Arc<Self>` receivers and `self.clone()` calls in `run_backend_job`, `idle_start`, and `try_read_task_output` — the deferred/background work reaches the backend through the pinned `turbo_tasks` handle instead, whose `Arc` already keeps the backend alive. - With no remaining sharer, the backend's own `Arc<TurboTasksBackendInner>` and the `TurboTasksBackend` newtype were collapsed: the inner fields now live directly in `TurboTasksBackend`, stored inline in `TurboTasks` (which already shares it via its own `Arc<TurboTasks>`). **Other cleanups surfaced by the above:** - `noop_backing_storage()` is now an empty, read-only instance of the real `TurboPersistence` instead of a separate `NoopKvDb` impl — same concrete type as `turbo_backing_storage()`, so the `Either<…>` wrappers in napi and turbopack-cli go away. - `TurboTasksBackend::backing_storage()` accessor replaced with a focused `invalidate_storage(reason_code)` method, so the backend no longer leaks its storage object. - Dead code removed (`CellDependency::key`, `ExecuteContext::schedule` / `suspending_requested`, the non-stats `execute_with_stats` variant) and feature/test-only items gated behind their corresponding `cfg`. ### Why? The no-op storage work removed the last thing forcing these abstractions to be generic/dynamic, so the single-impl traits and the extra `Arc` layers were pure indirection. This is a self-contained simplification: behavior is unchanged. ### Tradeoff - One `Either`/match in every backing-storage method becomes one `is_empty` atomic load (`Relaxed` `AtomicBool`) in `should_restore` — essentially equivalent. - The backend is now stored inline in `TurboTasks` rather than behind a pointer: one fewer indirection on every backend access, at the cost of a larger `TurboTasks` struct. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · a0dd2323 · 2026-06-05
- 0.5ETVfetch: respect HTTP Cache-Control headers with TTL-based invalidation (#91729) (#93228) ## Summary This is a re-application of the HTTP fetch part of #91729. Stacked on #93227. ### Fix fetch to respect HTTP `Cache-Control` headers Previously, `fetch` results were cached indefinitely, meaning results would never be refreshed (unless the cache was invalidated). Now they are `session_dependent` with a TTL to ensure we respect HTTP cache settings (e.g. Google Fonts with `max-age=86400`). New two-task pattern: - **`fetch_inner`** (NOT `session_dependent`): Performs the HTTP request, grabs an `Invalidator` for itself, and returns the response + invalidator + absolute deadline. Cached across sessions. - **`fetch`** (`network`, `session_dependent`): Reads the cached `fetch_inner` result and spawns a timer to invalidate when the TTL expires. On warm cache restore, `fetch` re-executes (session-dependent), reads the persisted deadline from `fetch_inner`'s cached result, computes remaining TTL, and spawns a timer — no HTTP request unless the TTL has already expired. Mid-session, the timer fires and triggers a re-fetch. Error handling: On fetch failure, `fetch_inner` takes a dependency on `Completion::session_dependent()` so transient errors (network down, DNS failure) are retried on the next session without busy-looping. ## Test Plan 3 new integration tests in `turbo-tasks-fetch/tests/fetch.rs`: - `ttl_invalidates_within_session` — mock server returns `max-age=1`, body changes, verifies re-fetch after TTL - `ttl_invalidates_on_session_restore` — fetches with TTL, stops TT, waits for expiry, warm restores with new TT, verifies re-fetch - `errors_retried_on_session_restore` — server returns 500, stops TT, fixes server, warm restores, verifies success - Existing 6 fetch tests continue to pass <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 2e90936d · 2026-05-14
- 0.5ETVfix bad socket file location (#2021) Co-authored-by: JJ Kasper <jj@jjsweb.site>github.com-vercel-workflow · 4cde3b96 · 2026-05-19
- 0.5ETV[turbopack] Remove turbotask functions from `trait ResolveOrigin` (#94324) ### What? Removes the `#[turbo_tasks::function]` annotations from the `ResolveOrigin` trait methods (`origin_path`, `asset_context`, `resolve_options`), turning them into plain synchronous trait methods. Callers obtain the value once via `Vc::into_trait_ref().await?` and then call the methods directly on the resulting `TraitRef`. This mirrors the earlier de-functionification of the `Issue` trait (#92623). ### Why? The `ResolveOrigin` implementations are all trivial — they return a stored field or delegate a single call. | Task (misses, before → after) | Misses removed | | --- | --- | | `ResolveOrigin::resolve_options` (dyn) | 92,608 → 0 | | `EcmascriptModuleAsset::origin_path` | 64,779 → 0 | | `ResolveOriginWithTransition::asset_context` | 52,292 → 0 | | `ResolveOriginWithTransition::origin_path` | 52,292 → 0 | | `EcmascriptModuleAsset::asset_context` | 41,164 → 0 | | `PlainResolveOrigin::{origin_path, asset_context}` | 105 + 105 → 0 | | `EcmascriptCssModule::{origin_path, asset_context}` | 32 + 32 → 0 | | `CssModule::{origin_path, asset_context}` | 19 + 19 → 0 | Net effect in that run: **~303k fewer cache misses** (total misses 6,197,068 → 5,893,545) — The ~1.35M eliminated cached-task *hits* additionally remove the per-call task lookup/scheduling overhead. The caching that genuinely matters is preserved one layer down — `AssetContext::resolve_options` (which `ResolveOrigin::resolve_options` delegated to) is still a cached task; its miss count is unchanged (39,668) since those distinct results were always computed, and it simply absorbs the call volume directly now (hit rate 57% → 92%). ## Build Performance: `ResolveOrigin` optimization Benchmark across 10 runs per branch. | Metric | Base | This branch | Δ | |---|---|---|---| | **Wall time** | 43.302 s | 42.692 s | **−0.61 s (−1.4%)** | | **User time** | 314.249 s | 310.919 s | **−3.33 s (−1.1%)** | | **Sys time** | 84.225 s | 84.618 s | +0.39 s (+0.5%) | | **MaxRSS** | 14106.1 MB | 13883.4 MB | **−222.8 MB (−1.6%)** | ### Notes on significance - **User time** is the cleanest signal: −1.1% with very tight variance (base σ 0.4%, branch σ 0.3%). The drop (~3.3 s) is well outside the noise — a real reduction in CPU work. - **MaxRSS** is down a solid 1.6% (~223 MB) with tight variance on both sides (σ ≈ 0.5–0.6%) — a clear, reproducible memory win. - **Wall time** improves 1.4%, but variance is large (base σ 6.6%, branch σ 4.5%, with one 50 s outlier in the base set), so treat this as directional. It tracks the user-time improvement. - **Sys time** is essentially flat (+0.5%, within its ~10% run-to-run noise) — no meaningful change, as expected for a CPU/allocation optimization. ### Invalidation tradeoff The main behavioral risk is invalidation granularity. Removing the per-method tasks removes their invalidation boundary: a consumer that previously depended only on the `origin_path()` (or `asset_context()`) task output now reads the value off the origin directly, so it is tied to the origin cell rather than to that one method's cached output. In practice this is narrow, because every `ResolveOrigin` implementor is an immutable, content-addressed `#[turbo_tasks::value]` (only `ResolvedVc`/owned fields) — a different field value means a different cell, so "reading the whole value vs. one field" does not by itself widen invalidation. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · c5f0da7b · 2026-06-05
- 0.5ETV[turbopack] Simplify local task tracking (#93478) ### What? Replace the `FuturesUnordered<Either<JoinHandle, ...>>` that tracks a global task's in-flight local tasks with a small in-place counter+`Event` bundled into a new `LocalTaskTracker` that lives directly on `CurrentTaskState`. ### Why? `wait_for_local_tasks` only needs a barrier — "have all in-flight local tasks completed?" — not a stream of outputs. `FuturesUnordered` was overkill for that, and its supporting machinery cost an allocation per parent task plus an allocation per local task, plus repeated lock acquisitions per registration and per completion. Per parent task execution, this PR removes: - **One `FuturesUnordered` allocation.** The lazy `Option<FuturesUnordered<…>>` is gone; the counter+event is inline on `CurrentTaskState`. - **One `tokio::sync::oneshot::channel()` allocation per local task.** `priority_runner::schedule_with_join_handle` (and `JoinHandle`) is deleted; local tasks now use plain `schedule`. - **One intrusive `FuturesUnordered::Task` node allocation per local task.** - **One `RwLock<CurrentTaskState>` write-lock acquisition per local task spawn.** The pre-existing `create_local_task` write lock now also bumps the in-flight counter, so we no longer take a separate lock to push into the tracker. - **One `RwLock<CurrentTaskState>` write-lock acquisition per local task completion.** The pre-existing `Scheduled → Done` write lock now also decrements the counter, notifies per-task waiters, and (if the count hit zero) notifies the collective wait event — all under one lock. Net effect on hot paths: - **Spawn**: was 2 allocations + 2 locks; now 0 allocations + 1 lock (the existing `create_local_task` lock). - **Wait when nothing was spawned**: a single read-lock that finds `in_flight == 0` and returns. No allocation, no listener, no await. ### How? - New `LocalTaskTracker` ([`turbopack/crates/turbo-tasks/src/local_task_tracker.rs`](turbopack/crates/turbo-tasks/src/local_task_tracker.rs)) bundles: - `tasks: Vec<LocalTask>` (was a separate field on `CurrentTaskState`), - `in_flight: u32` (plain integer; the surrounding `RwLock` provides synchronization), - `done: Event` (notified on transitions to zero). - `CurrentTaskState::local_tasks` is now `LocalTaskTracker` instead of `Vec<LocalTask>`. The wait-group is part of the same struct, so a parent task that doesn't spawn any local tasks pays nothing beyond the inline fields. - `LocalTaskTracker::create` pushes a new `Scheduled` entry and increments the counter. - `LocalTaskTracker::complete` does the `Scheduled → Done` swap, fires the per-task `done_event` (waking `try_read_local_output` waiters), decrements the counter, and notifies the collective `done` event if the count hit zero — all while holding one write lock. - `wait_for_local_tasks` is the standard double-check pattern (snapshot the counter, register a listener, re-check, await) against the tracker. No loop after the await — the codebase contract is that no new local tasks (or detached test futures) can be registered after the parent's user body returns, except from inside an already-in-flight scope, so a single notify is sufficient. - `spawn_detached_for_testing` uses a tiny `register_detached` / `dec_in_flight` pair (test path; no RAII guard, panics abort upstream). - `priority_runner::schedule_with_join_handle`, `JoinHandle`, the `Option<Sender<()>>` field on `HeapItem`, and the matching `tx.send(())` in `WorkerFuture::poll` are all deleted; nothing else used them. Unit tests for the tracker cover balanced create/complete, balanced detached register/dec, and that `complete` wakes per-task listeners. The existing detached integration tests (including the nested `spawn_detached_for_testing` case in `detached.rs`) still pass. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 025f16a0 · 2026-05-06
- 0.5ETV[turbo-trace-server] optimize loading even more (#93332) ## What Reduce time and memory spent loading large traces in `turbopack-trace-server`, focused on the row-ingestion hot path (`reader/turbopack.rs`). **Eager interning at parse time** - New `OwnedTraceValue` enum (lifetime-free) and `SpanArgs` reuse for `InternalRow` value lists. All borrowed `Cow<str>` keys/values are interned to `RcStr` once, at parse time, via a single `intern_span_args` helper. - `InternalRow`/`InternalRowType` shed their `'a` parameter. The `into_static` path is gone — previously, every row that had to be queued waiting for its parent allocated a fresh `String` for each Cow field, then the eventual processor re-interned those strings. Now both costs are gone: one intern, no String allocation. - `Event` rows pre-extract `duration` (from `TraceValue::UInt`) and `name` (from `TraceValue::String`) at parse time, so the per-event `FxIndexMap` `swap_remove` lookup goes away entirely. **Inline queue flush instead of working-queue extend** - `process_internal_row` and `process_internal_row_queue` collapsed into a single recursive method. When a `Start` row flushes queued children (i.e. orphaned rows whose parent has now appeared), those rows are processed inline via direct recursion instead of being `extend`ed into a working queue and re-driven. Eliminates the `Vec<InternalRow>::extend` memcpy that showed up as a top memmove site, and keeps the just-added `Span` hot in cache while its child events are applied. - `row_queue` field and the `take`/swap driver loop on `TurbopackFormat` removed. **New ChunkedVec storage for Span** - reduces costs when adding Span objects, since they are large and expensive to move - reduces peak heap due to resizing (in exchange for larger min sizes) ## Result Loading a 10 GB trace: **53s → 45s** wall clock, **21.5 GB → 18.8 GB** peak heap. Hitting 220mb/s loading speed <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · 66776bd9 · 2026-05-01