Repository navigation
Replies: 11 comments 1 reply
This comment was marked as off-topic.
This comment was marked as off-topic.
|
We'll need to see the trace to make a proper determination of what's causing the performance problems here. We can handle that over our Slack channel. There's a good chance that something else is causing your builds to take 338.5s, we rarely see builds that slow when using 30-core machines. Regarding parallelismWhile we haven't observed slower overall wall times due to additional cores, in our testing, we've found diminishing returns after about 16 cores. We haven't done much testing beyond ~30 cores as we have limited access to machines with that many cores, and that's the largest machine size that most of our customers use. There's a giant There are also just some steps in the build that aren't as trivially parallelized as others. We've fixed many of these, but we might be able to find stuff in your trace. Eviction
We run eviction during A couple things for you to check
It's also probably possible to set some/all of these policies by running under |
|
Follow-up with discriminating measurements on the module-graph phase (same app as above: ~2,070 routes, ~30k modules; 166 logical cores, Linux). We ran cold builds varying one knob at a time:
So the disk-read semaphore is not limiting (64→512 no change; even 8 barely hurts), and dropping to 32 workers (1/5 of cores) costs only ~8% — the phase is not CPU-parallel-bound.
~7,342 thread-seconds of futex wait in a 60 s window across ~166 worker threads ≈ ~120 workers asleep on locks at any instant, which matches the observed ~20 busy cores. Negligible file IO. Reading the v16.3.3 sources, this looks consistent with synchronous lock waits inside task execution rather than IO or compute:
Happy to run further instrumented builds (env-var arms, off-CPU stack samples) if useful — the 6 GB task trace offer from the issue body still stands. |
|
Results on your suggestions, plus a first pass over the trace ourselves. Machine note first: the parallelism arms below ran on a single-socket AMD EPYC 9D64 (88 cores / 176 threads), one NUMA node — so cross-node memory latency is ruled out on this box. (The original trace came from a similar-class single-socket host; I'll state topology alongside any future numbers.)
Monotonically worse as parallelism drops — so on this workload, reducing workers doesn't help, which argues against raw lock-throughput being the primary limiter (fewer contenders would have won). Together with the flat From the trace ( Next we'll get an on-CPU profile of the phase to see what the ~20 busy cores are executing (parse/resolve vs task-engine bookkeeping). And yes on the trace handoff via Slack — it's packaged. |
|
Closing the loop on the promised on-CPU look, plus the synthesis of all the arms. Thread-level sampling during the graph phase (perf tracepoints weren't available on this host, so: per-thread CPU-time deltas from The arithmetic that summarizes everything we measured: the phase contains 30,696 module tasks; each averages ~6.4 ms of exclusive time but stays open ~1 s — roughly a 150:1 ratio of engine/wait time to work per module. Summing the actual work spans (parse, process-module) gives a few hundred CPU-seconds for the whole app — i.e. tens of seconds at even modest parallelism — while the wall is 200+ s. And when work is runnable it runs wide (we saw ~87x inside process-module aggregates). So the picture is: plenty of parallel capacity, plenty of parallelizable work, but a narrow runnable frontier most of the time, with the busy threads spending their cycles in allocation/bookkeeping rather than compilation. Combined with the earlier arms (read-semaphore 8/64/512 flat; workers 16→176 within ~13% and fewer is worse; eviction and NUMA ruled out), the candidates that remain are the structural ones you alluded to: the single-driver graph traversal draining discovery bursts serially (e.g. a ~1,700-import barrel file releasing its children all at once), write-locked task-state touches even for reads, and counter propagation toward the root aggregation. Happy to run any discriminating experiment you want on the 176-thread host — and the full trace remains available through the private channel. |
|
We built the v16.3.3 napi bindings from source and A/B'd three engine-level patches against an unmodified from-source control on the same 176-thread host, same ~2,080-route app, two cold builds per arm (
Combined with the earlier knob sweeps (workers 16→176 within ~13%, read-concurrency flat, NUMA n/a single-node), everything cheap is now exonerated: allocator, lock pressure relief via fewer workers, driver wakeup overhead, and aggregation removal (which is strongly counterproductive). What remains consistent with all measurements is per-task engine latency times dependency-chain depth: one turbo-task per module edge, each paying the fixed schedule/execute/complete/notify cost, with the runnable frontier bounded by the import graph's structure rather than by any resource. If that's right, the wins are structural — fewer, coarser tasks per module (we notice a Happy to run any candidate patch you want measured on this host — the from-source A/B loop is now ~20 minutes per arm. |
|
We instrumented the v16.3.3 engine (relaxed-atomic counters at four lifecycle points, from-source build, Where a 270 s compile's thread-time goes (176 threads = 47,520 thread-seconds of capacity; instrumented build ~10-15% slower than stock, segments overlap — aggregation includes its nested lock waits):
Volume context: 8.59 M task executions, 94.3 M output/cell reads, 59.2 M Patch experiment: shard the scheduler queue (64 sub-heaps, approximate-emptiness counter, try_lock scan + blocking fallback; global priority order relaxed to per-shard — patch attached, 131 lines):
Earlier same-rig arms for completeness: Our takeaway: the engine is convoy-limited at whichever lock is next in line — relieving one moves the wait, so micro-fixes cap out around ~10% on this shape. The op-count-per-module (280 tasks/module) is the structural lever; that matches where |
|
One correction to our last two comments: we cited One related datum from the same counters that's worth having when weighing granularity changes: reads outnumber task executions ~11:1 (94.3 M reads vs 8.59 M executions), so the task cache is doing real dedup work — the win isn't fewer cached nodes, it's fusing intra-module chains (module → parse → analyze → per-reference resolution) so the cache boundary sits at module granularity rather than per-step. |
|
Update, and it starts with a correction to my own framing. Everything we have reported in this thread was measured on 16.3.3. Going to open PRs against canary, I found So I am not going to post a "your scheduler is the bottleneck" patch, because you already know and have acted. What follows is: the one change that is still orthogonal to that fix and is open as a PR, an offer to measure your fix at a scale you have said you cannot reach, and the findings that do not depend on the scheduler-queue measurement. Same app and host as every comment above (~2,080 routes, ~30.7k modules, 88c/176t single-NUMA EPYC, production build), each arm against an unpatched from-source control on the same machine in the same session. An offer: we can measure
|
| cache state | wall | tasks re-executed |
|---|---|---|
| cold, no cache | 281 s | 8,609,739 |
| fresh, same revision | 44 s | 0 |
| 2 days stale, window contains a dependency bump | 301 s / 299 s | 8,609,739 |
| 1 day stale, source churn only, 290 files | 252 s | 135,030 (1.6%) |
| 2 days stale, source churn only, 319 files | 377 s | 1,602,768 (18.6%) |
- In these runs, a cache that crossed a dependency change was slower than no cache. It re-executed every task, the same 8,609,739 as a cold build to the digit, then paid restore, dependency tracking and rewrite on top of what was effectively a cold build. A framework bump makes it certain: the cache directory is keyed by release (
turbopack/v16.3.3-<hash>/), so a bumped binary starts a fresh keyspace and the restored gigabytes buy nothing. - With manifests matching, file count predicts nothing; import reach does. Two windows of nearly identical size landed at 1.6% and 18.6% invalidation. The first beat cold by 29 s. The second lost to cold by 96 s. The difference is which files changed: the slow window's set includes shared modules whose importers all invalidate with them.
- Synthetic churn will tell you the cache is always fine. Appending a comment line to 600 and then 1,200 real
.tsxfiles re-executed only ~2 tasks per file and stayed far under cold, because the compiler re-parses each changed file, finds its meaning unchanged, and stops. Any benchmark built on synthetic edits misses the inversion entirely.
Practically this means "turn the filesystem cache on in CI" is not safe advice, which is not what we expected to conclude. A cache is reliably a win when it is near (same revision, or one change away). Past that it is a function of the invalidated fraction, and we could not find a cheap way to read that fraction before committing to a restore. The only pre-build signal we found that reliably predicts total loss is a dependency-manifest mismatch (package.json plus lockfile, byte-compared), so we now wipe when they differ. Necessary and clearly not sufficient: the 18.6% window had matching manifests and still lost.
Three questions:
- Is there a supported way to ask a persisted cache how much of it a given source state invalidates, short of restoring it and building?
- Could the engine short-circuit the "restore, discover it is all invalid, pay for the rewrite anyway" path once invalidation passes some fraction?
- Is the release-keyed cache directory intended to make cross-version reuse impossible, or is a migration planned? Given how often a framework bump lands in a normal week, that case alone may deserve an explicit skip-the-restore rule.
Independent of the answers, we think the docs should say plainly that a sufficiently stale cache is slower than no cache, because the failure is silent: the build still succeeds, it is just slower than the build you were trying to speed up.
One app, one machine, one release behind
One app, one machine, one week, and now one release behind. Our confidence is uneven on purpose. The mechanisms we are confident about because they are direct measurements with controls: the counter breakdown, the two failing priority tests, the invalidation fractions, the Rust-compiler comparison. The magnitudes we are much less confident about transferring, and the scheduler magnitude specifically is now known to be measured against superseded code. If any of this contradicts your own benchmarks, we would rather hear it than have it adopted quietly.
|
We rebuilt everything on canary rather than keep hedging about 16.3.3, and it changed three conclusions, including one of ours that does not survive. Same app and host as above, plus two smaller apps; every arm below is stock-vs-patched on the same box, interleaved, with the loaded engine verified by matching the sha256 of the
|
| cores | parent | child (with #96808) |
delta | ranges |
|---|---|---|---|---|
| 16 | 528 / 531 / 537 | 518 / 519 | child 14 s faster | separate |
| 30 | 395 / 400 / 403 | 380 / 396 | child 11 s faster | overlapping |
| 166 | 281 / 301 / 306 / 307 / 312 | 319 / 322 / 324 / 326 | child 21 s slower | separate |
task_miss is identical across arms at every point, so nothing about the work changed; this is purely scheduling. At 16 and 30 cores the change does what it says. At 166 threads every child run is slower than every parent run, about 7%. We are not claiming to know why, and we would rather be told we are holding it wrong than have this taken as a regression report. If you want a specific arm re-run, or the inline_execution_stats counters against it, say which and we will.
Two corrections we owe you about the queue sharding
In our last comment we said the sharding patch deliberately fails two of your priority_runner tests, and that "the win largely is the relaxation" of global priority order. Having ported it to canary's restructured Queue, both statements are wrong.
The port aims at global-max ordering by caching each shard's top priority in an atomic, and it passes both ordering tests:
cargo test --offline -p turbo-tasks --lib priority_runner
test priority_runner::tests::test_cpu_bound_tasks ... ok
test priority_runner::tests::test_cpu_bound_with_yield_tasks ... ok
test priority_runner::tests::test_mixed_cpu_bound_and_waiting_tasks ... ok
test result: ok. 12 passed; 0 failed; 0 ignored
Each ordering test also passes run individually with --exact, and the whole crate matches a stock control. Correcting one thing here: passing those tests is not the same as preserving the ordering, and a code review of our port found that it does not. pop picks a shard from the cached tops and then pops it without revalidating, so two poppers that select the same shard, or a stale-high top left behind by claim, can return a lower-priority item while a higher one sits in another shard. The existing tests do not cover it because they cannot reliably produce that interleaving. We are fixing pop to revalidate after taking the shard lock and will re-measure, since that costs an extra peek and an occasional rescan. So the contract question we put to you last time is still open rather than answered.
The sharding win is real and it is a wide-machine effect only
| cores | canary stock | canary + sharding | delta | noise | ranges |
|---|---|---|---|---|---|
| 166 | 317 / 319 / 320 / 321 / 325 | 274 / 277 | 44.9 s faster (14.0%) | 8 s | separate |
| 30 | 375 / 377 / 385 / 388 / 390 / 393 | 376 / 382 / 392 | 1.3 s | 13 s | overlapping |
task_miss and page count are identical in every run, so again this is scheduling only. The 16-core cell is still running and we will post it whatever it says.
The mechanism, with both engines instrumented identically on one host at 166 threads:
| arm | queue-lock wait | acquisitions | contended | rate |
|---|---|---|---|---|
| stock | 24,898 thread-s | 67,840,156 | 36,515,476 | 53.8% |
| sharded | 5,436 thread-s | 56,007,229 | 11,324,956 | 20.2% |
4.58x less blocked time. That also explains the 30-core row rather than contradicting it: at 166 threads more than half of the acquisitions are contended and the lock is genuinely the constraint, and at 30 cores there is not enough concurrency for it to bind. So the honest description is not "makes builds 14% faster", it is "removes a bottleneck that only binds above some core count, somewhere between 30 and 166". Taken with the #96808 table above, the two changes have opposite signs at the two ends, which suggests the scheduler wants different behaviour at 30 cores and at 166 rather than one setting for both.
Our own patch did not survive the move to canary
The task-fusion change in #98091 is a wall-clock null on canary. Nine cells, three apps at 16, 30 and 166 cores: eight are inside that configuration's own noise, and the only mover is the smallest app at 30 cores. The mechanism is exactly as described, and reproducible: task_miss drops 2.6% to 3.7% in every cell, which is the ~223k task executions. It just does not show up in wall time.
Two things about that we should say plainly. The 279 vs 298 we quoted earlier was measured with the filesystem cache off, while these are default-config cold builds, so it was never comparable to what we are reporting now. And we are retracting one number from our last comment: a 4.5 s improvement on the mid-size app at 166 cores came from rows that had not proved which binary they loaded; hash-verified, the same cell re-measures at 1.0 s, inside noise. The PR stays in draft while we measure whether removing those tasks removes their memory, since the original post in this thread flagged allocator pressure and 28.6 GB of live bytes as a suspect. If that is flat too, we will close it and say so here.
|
Corrections to my last two comments, both from measuring rather than from argument. The sharding number was wrong, and so was the ordering claimA code review of our port found that Fixing it costs most of the win:
Non-overlapping ranges, n=7 stock and n=5 patched, The lock table we posted is withdrawn too, because it was instrumented on the defective engine. Measured on the engine that matches the 18.1 s:
2.29x less blocked time, not the 4.58x we reported, and acquisitions rise 17.5% because a revalidating pop that finds a stale hint drops the lock and comes back. The cost of correctness is large and worth stating: 49.7% of pops find their hint stale and rescan, and 14.3% fall through to a blocking all-shards pass. At that rate it is fair to ask whether the per-shard hint earns its place; we do not have a better answer than the measurement. Correcting the paragraph that was here: I wrote that roughly 60% of the win disappears when ordering is genuinely preserved, and put the strict-versus-relaxed policy question back to you. That was wrong, and measuring it rather than reasoning about it is what showed the error. We built a deliberately relaxed variant on the fixed tree, identical except that
Relaxing the ordering buys nothing: 1.0 s in favour of strict, with the ranges overlapping. The relaxed arm fails only What we cannot yet explain is the rest of the gap. The defective engine measured 44.6 s faster than stock and the corrected one measures 16 to 18 s, and since revalidation is not the cause, something else in the correctness fixes is. We are bisecting that now and will report it, including if the answer is that our own fix is simply leaving performance on the table. Updated PR: #98093. We closed our own other PR#98091 (task fusion) is closed. On canary it is a null on wall clock (8 of 9 app-and-core-count cells inside noise), on peak RSS, and on TurboMalloc peak live bytes, which was the tighter instrument at a 0.10 GB stock spread and showed no reading lower than stock. It reliably removes ~223k task executions per build and that converts into nothing measurable, which is itself the useful finding: task count on its own is not a good optimization target here. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
On a large Next.js App Router application (2,070 routes, roughly 16,000 first-party TypeScript files, module graph of ~31,000 modules),
next buildwith Turbopack spends 60% of its wall time in a phase that uses almost no parallelism, while the rest of the build parallelizes well.Environment: Next.js 16.3.3, Node 22, Linux, 166 logical cores, production build,
experimental.turbopackFileSystemCacheForBuildat its default. Timings below are from a cold build traced with NEXT_TURBOPACK_TRACING=turbopack and read back throughnext internal trace/query_spans.The compile ("Creating an optimized production build" through "Compiled successfully") takes 338.5s. Its breakdown by corrected duration:
next_api::instrumentation instrumentation endpoint->next_api::project whole app module graph: 203.6sturbopack_core::module_graph module: 30,696 spans, corrected durations totaling 195.5s (average 6.4ms per module).next_api::project emitting: 42.1snext_api::app app endpoint HTML: 1,778 spans totaling 39.2s (all pages combined)next_api::app app endpoint route: 398 spans totaling 23.2sThe phase is highly concurrent in task terms but starved in CPU terms. Its raw span time is 30,036s inside 203.6s of wall (cpuDuration 3,003,601,571,200 ticks at 100 ticks/us vs correctedDuration 20,361,263,794), i.e. on the order of 150 tasks in flight on average. Yet external process CPU sampling shows the build averaging only ~22 cores over the compile (with peaks near 128 during page compilation), so during graph construction the in-flight tasks appear to spend most of their time waiting rather than executing. Adding hardware does not shorten the phase.
A possible contributor visible in the same trace: allocator pressure. TurboMalloc live-bytes samples on the phase peak at 28.6 GB with maxPressure=2 recorded, and the task engine's eviction threshold scales down under memory pressure (default TURBO_ENGINE_EVICT_MIN_BYTES is 128 MiB). If eviction and recomputation kick in mid-construction, that would convert concurrency into churn. Not yet verified; happy to A/B with the env knobs if that is a plausible mechanism.
Two secondary observations from the same trace, possibly related:
Ask: what bounds throughput of whole-app module graph construction for large graphs (coordination, allocator pressure, lock contention)? The phase sets a hard floor on build time for large apps that no amount of cores changes, and any guidance on knobs or instrumentation to narrow it down would be appreciated. Happy to provide the 6 GB trace file and reproduce with other tracing presets.
All reactions