Skip to content

fix(core): backport the planning, hashing and worker task graph changes to 22.7.x - #37083

Open
AgentEnder wants to merge 4 commits into
22.7.xfrom
backport/hashing-perf-22.7.x
Open

AgentEnder wants to merge 4 commits into
22.7.xfrom
backport/hashing-perf-22.7.x

Conversation

@AgentEnder

@AgentEnder AgentEnder commented Sep 17, 2026

Copy link
Copy Markdown
Member

Current Behavior

22.7.x hashes tasks and sends the task graph to workers the way 23.2 did before #36992, #36994 and #37015.

Expected Behavior

Backport of the three merged planning, hashing and worker-transport changes, cherry-picked in order:

What differs from master:

  • hash_planner.rs on this branch keeps the reference-keyed external dependency map from the earlier memory backport, so the two insert calls index it by reference. The tests brought over needed the Task::new and with_outputs helpers, added to types.rs.
  • The planner snapshot entries were regenerated under Jest, since this branch does not run Vitest for packages/nx; the three new snapshot values are byte-identical to master's.
  • The daemon client on this branch does not strip run results before a hashing request, because feat(core): hash a continuous dependency's inputs into the task it serves #37017 is not here; pruneTaskGraph is wired only into the five worker send sites.
  • The planner snapshot and the hasher tests are written against Jest. Jest runs each test file in a vm context with its own Object, so the four hasher tests that assert on a result's prototype resolve the host realm's Object.prototype first.
  • A fourth commit caps @nx/webpack's webpack dependency below 5.111.0, which breaks module federation builds. Master has the same cap in the init generator from fix(webpack): cap the generated webpack version below 5.111.0 #37034; that generator does not install webpack on this branch, so the cap lives on the package dependency instead. Any new module federation workspace on 22.7.x hits this today, and the e2e suites that cover it only ran here because a change under packages/nx marks everything affected.

Verified locally: cargo check --all-targets and cargo test -p nx (399 passing) on Rust 1.94.0, and the planner, hasher, daemon client, pruning and worker-transport specs under Jest.

Related Issue(s)

Backport of #36992, #36994 and #37015 to 22.7.x, with the webpack cap from #37034 carried as a package dependency.

…36992)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

The hash planner turns each external dependency name into an owned
`External` instruction and interns it as it walks. Projects that share
an npm closure hit the same names repeatedly, so the planner allocates
and interns duplicates, and the id vector retains capacity for them
until the later sort and dedup.

Borrowed names are collected in a set first, so each unique external is
interned once. Plans and hashes are unchanged.

Measured on a synthetic fixture of 600 projects sharing one closure of
100 packages, medians of seven interleaved rounds against current
master:

| Measure             | master | this PR |
| ------------------- | -----: | ------: |
| `hashMultipleTasks` | 746 ms |  646 ms |
| tasks peak RSS      | 380 MB |  351 MB |

Graph construction does not move, and all 600 task hashes are identical
to master.

<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #

Co-authored-by: Kyle Cannon <867978+kylecannon@users.noreply.github.com>
@AgentEnder
AgentEnder requested a review from a team as a code owner September 17, 2026 02:10
@nx-cloud

nx-cloud Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit ee8176e

Command Status Duration Result
nx affected --targets=lint,test,build,e2e,e2e-c... ✅ Succeeded 51m 22s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 3s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 16s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 20s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 6s View ↗

☁️ Nx Cloud last updated this comment at 2026-09-17 14:32:34 UTC

kylecannon and others added 2 commits September 16, 2026 23:24
Stacked on #36992. The diff here contains the follow-up native planner
and hasher changes.

## Current Behavior

Native planning and hashing repeat work across shared dependencies. The
planner concatenates and sorts every repeated dependency ID before
deduplicating them. The hasher accumulates each instruction into shared
result maps, sorts long keys per task, and converts those maps to
JavaScript by recreating every property name. Noninteractive runs still
format TUI trace events for individual hash entries.

## Expected Behavior

Task hashes and the hash format stay the same. The changes reduce the
work needed to produce them.

- Union dependency IDs in a temporary bitset before allocating the final
lists.
- Gather hash results locally per task, order them using shared integer
key ranks, and convert contiguous details to JavaScript while reusing
string handles.
- Skip TUI trace collection when stderr cannot support a TUI, unless the
existing capability override is enabled.

### Measured impact

These synthetic results were recorded before the five latest follow-up
commits. They are retained as historical evidence, not measurements of
the final head.

Measured on a synthetic fixture built for this change: 600 projects
across 8 layers sharing one closure of 100 packages, giving 60,362
project-to-external edges against 1,553 internal ones. That fan-in is
the shape the planner's deduplication collapses.

All three arms share one TypeScript build from master and differ only in
the release Rust binding, which is possible because these commits change
no shipped TypeScript and no N-API signatures. Medians of seven
interleaved rounds against master
[`29200561dd`](2920056),
arm order reversed on alternate rounds, one warmup per arm discarded.

| Measure | master | #36992 | this PR | vs master | vs #36992 |
| --------------------- | -------: | -------: | -------: | --------: |
--------: |
| `hashMultipleTasks` | 746 ms | 646 ms | 387 ms | -48.2% | -40.2% |
| `run-many` wall | 2,173 ms | 2,117 ms | 1,808 ms | -16.8% | -14.6% |
| tasks peak RSS | 380 MB | 351 MB | 328 MB | -13.5% | -6.6% |
| tasks RSS footprint | 624 MB | 597 MB | 572 MB | -8.3% | -4.2% |
| cold graph (control) | 3,310 ms | 3,291 ms | 3,279 ms | -0.9% | -0.4%
|
| warm graph (control) | 270 ms | 271 ms | 269 ms | -0.4% | -0.7% |

Graph construction is the control and does not move, which is what a
change confined to the planner and hasher should show.

### Hash compatibility

All 600 task hashes are identical across the three arms. The sorted
task-id to hash listing digests to the same SHA-256 in each, so the
speedup does not come from computing something different.


### Scope and tradeoffs

The fixture is synthetic and the runs are macOS ARM64 on a shared
machine. They cover native planning, hashing, and N-API conversion, and
exclude Cloud and DTE. Linux and Windows validation remain outstanding.

Some small or sparse workloads are slightly slower. The bitset's
temporary size depends on the highest instruction ID, so a check that
primed 20,000 unrelated projects before planning a 16-project chain went
from 0.146 to 0.195 ms with essentially unchanged RSS. A small-call
check measured one cold task at 31.8 to 33.0 ms.

<details>
<summary>Implementation details</summary>

### Union dependency IDs before building the final lists

`packages/nx/src/native/tasks/hash_planner.rs` adds a temporary
`InstructionIdSet` backed by a bitset. Dependency IDs are inserted into
that set as child closures are merged, then emitted into an exactly
sized, sorted vector. The bitset is discarded, and memoized results keep
compact vectors.

This avoids concatenating and sorting all repeated IDs, and avoids
retaining vector capacity sized for the duplicates. For R incoming IDs
and a highest pool ID U, insertion is O(R), followed by scanning O(U/64)
words and emitting the unique IDs. Cycle detection, the legacy fallback,
and final plan ordering remain in place.

### Accumulate hash work locally and publish one result per task

`packages/nx/src/native/tasks/task_hasher.rs` replaces the flattened
instruction iterator and shared per-instruction accumulation with
parallel task processing. Each worker gathers its detail entries and
collected inputs locally, then publishes the completed task result once.
The separate shared input-accumulator map is removed.

Instruction types are classified once per pool entry. Shared-value cache
hits can be copied directly without taking the instruction pool's shard
lock, and the task environment is resolved once per task. Only pending
work enters the inner parallel iterator. Keeping that inner iterator
matters, because a single task with several expensive runtime or file
inputs can still process them concurrently.

Environment and runtime instructions remain outside the shared-value
slots because their values depend on the task environment. When input
collection is enabled, instructions still run the collection path for
each task.

### Rank hash keys once and keep details in contiguous storage

The hasher sorts the pool's detail keys once using the existing UTF-8
ordering, assigns integer ranks, and sorts each task's entries by rank.
Hash assembly feeds the same ordered value bytes into xxh3, with the
existing concatenation and output format.

Different instructions can have the same display key. Those keys share a
rank, and a stable sort with duplicate resolution preserves the existing
last-value-wins rule. The final entries stay in a vector instead of
being inserted into a native hash table and then enumerated and sorted
again.

Assembly now happens in the task worker, which removes the separate
assembly pass. Debug timing includes assembly within hashing, so the old
separate assembly-duration field and the final per-task hash-value trace
are removed.

### Reuse JavaScript handles for detail keys as well as values

`packages/nx/src/native/types/shared_str.rs` adds `SharedStrMap`,
exported through `types.rs` and used by `HashDetails`. It converts the
contiguous entries directly into a JavaScript object and routes both
property names and values through the existing `SharedStr` handle cache.
Previously the generic map conversion recreated property-name strings
for each task.

The returned results remain ordinary objects with the same fields and
writable, enumerable, configurable properties. Detail properties are now
emitted in canonical key order, where the previous native map supplied
unspecified iteration order. The measured JavaScript heap results are
mixed, so this does not claim a consistent retained-heap reduction.

### Avoid formatting TUI traces when the TUI cannot run

`packages/nx/src/native/logger/mod.rs` installs the TUI tracing layer
only when stderr is a terminal or `NX_TUI_SKIP_CAPABILITY_CHECK=true`.
This follows the existing task runner's terminal capability requirement,
and console and file logging retain their own filters.

The previous layer formatted and buffered trace events even in a
noninteractive process. Hashing emits a trace for every detail entry, so
that meant millions of formatting operations and temporary allocations.
The buffers are bounded, so the problem is the repeated work rather than
an unbounded log history.


</details>

<details>
<summary>Regression coverage</summary>

`packages/nx/src/native/tests/task-hasher.spec.ts` covers batch and
single-task equivalence, UTF-8 versus UTF-16 ordering, ordinary object
descriptors, repeated and frozen results, serialization, per-task
environments, live JSON changes, input collection, empty selections, and
recovery after missing environments.

The Rust additions check bitset word boundaries, ranked assembly against
an independent map implementation, duplicate-key resolution, Unicode
ordering, empty assembly, and TUI logging filters. Separate V8
scavenge-stress checks with a 1 MiB semi-space matched the prior
baseline, and console and file logging checks passed.

</details>

## Related Issue(s)

<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

No linked issue. This came from investigating memory use during
concurrent `nx affected` commands in CI.

---------

Co-authored-by: Kyle Cannon <867978+kylecannon@users.noreply.github.com>
Co-authored-by: Craigory Coppola <craigorycoppola@gmail.com>
Every fork received the whole task graph, re-serialized by the runner each
time, with per-task hash details that only Nx Cloud reads on the runner side
and the terminal output the Nx Cloud life cycle writes back onto finished
tasks. Both are most of every task's size and grow the payload as the run
goes on.

Each fork now gets a copy without any run result, made once per graph
object by pruneTaskGraph, over the same JSON channel as before. Structure
never changes mid-run, so the copy never goes stale. On a flat 600-task
fixture at parallel 8 the tasks phase drops from 45.9 s to 29.0 s, runner
peak RSS from 568 MiB to 276 MiB, and per-worker peak RSS from 182 MiB to
112 MiB; on a dependency-chain fixture the same run drops from 142.7 s to
126.6 s with the same memory savings. Hashing is unchanged.

Backport note: on master the daemon client strips the same fields before a
hashing request and shares pruneTaskGraph; that stripping arrived with
#37017, which is not on 22.7.x, so here pruneTaskGraph is wired into the
worker send sites only.
@AgentEnder
AgentEnder force-pushed the backport/hashing-perf-22.7.x branch from 3a08a2c to 2a531db Compare September 17, 2026 03:25

@nx-cloud nx-cloud Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

At least one additional CI pipeline execution has run since the conclusion below was written and it may no longer be applicable.

Nx Cloud is proposing a fix for your failed CI:

We extended the webpack version cap (>=5.101.3 <5.111.0) to @nx/module-federation, matching the fix already applied to @nx/webpack in this PR. Without this cap, pnpm resolved @nx/module-federation's uncapped ^5.101.3 webpack dependency to 5.111.0 in e2e workspaces, causing @module-federation/enhanced to peer against webpack@5.111.0 while the build executor ran under webpack@5.110.3 — producing the [contenthash:20] not implemented in this context crash.

Warning

We could not verify this fix.

diff --git a/packages/module-federation/package.json b/packages/module-federation/package.json
index a244c324..d33dde1a 100644
--- a/packages/module-federation/package.json
+++ b/packages/module-federation/package.json
@@ -35,7 +35,7 @@
     "http-proxy-middleware": "^3.0.5",
     "picocolors": "catalog:",
     "tslib": "catalog:typescript",
-    "webpack": "^5.101.3"
+    "webpack": ">=5.101.3 <5.111.0"
   },
   "devDependencies": {
     "nx": "workspace:*"

🔔 Heads up, your workspace has pending recommendations ↗ to auto-apply fixes for similar failures.

Apply fix via Nx Cloud  Reject fix via Nx Cloud


Or Apply changes locally with:

npx nx-cloud apply-locally HaR9-1jTv

Apply fix locally with your editor ↗   View interactive diff ↗



🎓 Learn more about Self-Healing CI on nx.dev

…ion builds

webpack 5.111.0 fails federation builds with "Path variable [contenthash:20]
not implemented in this context" (webpack/webpack#22034). Master pins the
version the webpack init generator writes into a workspace (#37034); this
branch's generator did not add webpack at all, so a new workspace took
whatever @nx/webpack, @nx/module-federation or @nx/rspack resolved.

The generator now adds `webpack: ~5.110.0`, and the three packages that
depend on webpack cap it below 5.111.0 for workspaces that never ran init.
The lockfile already resolved a version inside the cap, so only its
specifier lines change.
@AgentEnder
AgentEnder force-pushed the backport/hashing-perf-22.7.x branch from 9fa5050 to ee8176e Compare September 17, 2026 13:36
@AgentEnder AgentEnder added scope: core core nx functionality type: bug labels Sep 20, 2026
@AgentEnder AgentEnder self-assigned this Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: core core nx functionality type: bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants