Skip to content

refactor(path): replace ResolverPath with interned UstrPath - #290

Draft
stormslowly wants to merge 27 commits into
mainfrom
refactor/interned-ustr-path
Draft

refactor(path): replace ResolverPath with interned UstrPath#290
stormslowly wants to merge 27 commits into
mainfrom
refactor/interned-ustr-path

Conversation

@stormslowly

@stormslowly stormslowly commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Why

Dependency paths handed out through ResolveContext are stored per-module downstream. The same tsconfig.json ends up in every module's file_dependencies, and each push allocated a fresh Arc<Path> — so a 10k-module build held 10k separate heap copies of one string.

before after
handle ResolverPath { Arc<Path>, u64 }, 24 bytes UstrPath(Ustr), 8 bytes, Copy
per push fresh heap allocation + memcpy pointer copy
equality memcmp pointer compare
hash precomputed, stored inline precomputed, one load from the interner entry header

rspack already depends on the same ustr-fxhash 1.0.1, so both crates share one interner static: a path interned here is already interned for rspack. ResolveContext's sets are now keyed by ustr::IdentityHasher, the same concrete type as rspack's ArcPathSet, so rspack can mem::take them instead of re-bucketing every element.

What

  • UstrPath — 8-byte Copy handle, Deref<Target = Utf8Path> (so the whole Path-shaped API comes for free), Hash writes the interner's precomputed FxHash.
  • ToUstrPath — conversion entry point, implemented for str, String, Utf8Path, Utf8PathBuf, Path, PathBuf, UstrPath. The two std::path impls panic on non-UTF-8 with path should be UTF-8, matching what the resolver already did at every Path -> Utf8Path boundary.
  • UstrPath::new is the only interning entry point, and therefore the only place Windows normalization happens — separators unified to \, repeated separators collapsed, trailing separator dropped (roots excepted), drive letter uppercased. This preserves the dedup semantics std::path::Path's component-wise Hash/Eq used to provide on Windows.
  • Memoized handles on CachedPathImpl (dep_path, node_modules_dep_path, package_json_dep_path) so repeat dependency pushes cost a Copy rather than re-entering the interner.
  • Cache::tsconfig takes an already-interned handle; the configured config_file is interned once at resolver construction. Interning on every lookup would have serialized all threads on one parking_lot bin mutex, since a resolver's config_file is a constant string and ustr picks its bin from the hash's high bits.

Interning is confined to terminal paths. Intermediate joins (extension probing, node_modules walks) still land in Utf8PathBuf — most of those paths do not exist, and interned strings are never freed.

Breaking changes — needs a paired rspack change

  • ResolverPath is removed. as_path() becomes as_std_path(); as_arc() / into_arc() are gone.
  • PackageJson::path / realpath are UstrPath; PackageJson::directory() returns &Utf8Path.
  • Resolution gains ustr_path() as a zero-copy exit. path(), into_path_buf(), full_path(), query(), fragment(), package_json() keep their exact signatures.
  • ResolveContext's dependency sets are UstrPathSet.
  • UstrPath does not implement Ord. Anything sorting dependency paths needs sort_by_key(|p| p.as_std_path()).
  • Resolution's PartialEq narrows from Utf8PathBuf::eq (component-wise) to pointer equality. On unix /a/b vs /a/b/ and /a//b vs /a/b now compare unequal.
  • On Windows, normalization changes the literal string returned by Resolution::path() / full_path() / into_path_buf(), PackageJson::path / realpath / directory(), and every dependency-set entry — not just their hash/eq. resolve("c:/proj", "./foo.js") now yields C:\proj\foo.js.
  • Interned strings are never freed, so Cache::clear() no longer reclaims path memory. The bound is the project's distinct path count; ?query / #fragment are separate Resolution fields and never enter a path.

Two accepted divergences from std Path on Windows: UNC/verbatim paths (\\?\…, \\server\share) pass through untouched, and . components are not dropped. Unix already behaved the latter way, so this makes the two platforms consistent rather than regressing Windows.

std's Path hash/eq folds the drive letter to uppercase (Prefix::Disk), so
c:\a\b and C:\a\b are one dependency today. Byte-wise interning without this
fold would split that into two paths, defeating the dedup this task exists
to preserve.
Swap ResolveContext's file/missing dependency collections, all
add_file_dependency/add_missing_dependency call sites, and
CachedPathImpl's output channel from the Arc<Path>-backed ResolverPath
to the interned UstrPath. ResolverPath and its hash_path helper are
gone; the CachedPath DashSet's hash helper moves into cache.rs as the
private hash_utf8_path, unrelated to interning.

This is a pure type swap with no memoization yet (that's next): every
add_*_dependency call does a naive to_ustr_path() intern lookup.
…ext path

add_file_dependency/add_missing_dependency took P by value, so
ToUstrPath::to_ustr_path (and the Ustr::from intern it does under
missing_dependencies/file_dependencies == None) ran unconditionally at
every call site before the `if let Some(deps)` guard could skip it.
Interning takes a global sharded lock and permanently allocates on
miss, so this silently added lock contention and unbounded allocation
to the default resolve() path, which never populates a context.

Switch both methods to <P: ToUstrPath + ?Sized>(&mut self, dep: &P) so
the interning stays inside the Some branch, and revert the 8 call
sites this affected back to passing references. Drop the
needless_pass_by_value allows this shadowed.

Also add a direct test for hash_utf8_path (cache.rs), which lost its
only coverage when resolver_path.rs was deleted.
Repeated is_file/is_dir dependency pushes for the same CachedPathImpl
re-entered the global ustr interner (FxHash + sharded mutex + probe)
on every call. Memoize the interned path, the node_modules dependency
path, and route the package_json cache-miss error branch through the
already-memoized package_json path, so repeats degrade to a Copy.

The node_modules_dep_path call site (cached_node_modules's cache-hit
replay) previously had no dependency-tracking guard around it, since
it built a plain Utf8PathBuf that only got interned inside the guarded
add_missing_dependency body. Memoizing moves the intern call to the
call site itself, so it now needs its own
ctx.missing_dependencies.is_some() guard to keep interning gated
behind active dependency tracking.
Resolution.path, CachedPathImpl.canonicalized, and realpath()'s return
type move from Utf8PathBuf to the interned, Copy UstrPath. Both arms of
realpath's cache-hit fast path and both branches of load_realpath now
avoid allocating.
Proves the UstrPath work through the real resolve path: repeated resolves
push the same interned pointer (not a fresh allocation) per dependency,
and re-interning an already-seen path hits the existing entry. Adds an
ignored test that prints ustr::num_entries()/total_allocated() as one-off
memory evidence, reproducible via `cargo test -- --ignored --nocapture`.
repeated_resolves_share_one_pointer_per_dependency compared snapshots
across resolves but never checked they were non-empty, so an
empty file_dependencies set would have passed without proving anything.
Cache::tsconfig interned `path` on every call, including cache hits.
Since options.tsconfig.config_file is constant per resolver, every
thread hitting load_tsconfig_paths (re-entered per resolve for
top-level lookups, alias candidates/redirects, and package
self/imports subpaths) serialized on the same ustr interner bin
mutex for a hash that never changes.

Cache::tsconfig now takes an already-interned UstrPath instead of
interning internally. ResolverGeneric caches
options.tsconfig.config_file as UstrPath once at construction and
passes it straight through on the hot path; the cold extends/
references call sites still intern inline.
…e-export

Three comments in cache.rs still referred to hash_path, deleted when it
moved into this file and was renamed hash_utf8_path, and claimed the
unix byte-wise hash matches CachedPath's PartialEq. It does not:
PartialEq is component-wise via std Path, so paths differing only in
trailing/repeated separators compare equal but hash differently on
unix. This predates this branch and never produces a wrong result
(mismatched hashes just land in different DashSet buckets), only a
duplicate cache entry — the comments now say so honestly.

Also documents why ustr_path.rs re-exports ustr::IdentityHasher
verbatim instead of a local newtype: rspack's ArcPathSet needs the
exact same type for mem::take to work. That upstream item is
#[doc(hidden)] and therefore outside semver; the ustr version pin in
Cargo.toml is what actually protects the re-export.
@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 9.19%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 5 improved benchmarks
✅ 7 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory resolver[single-thread] 7 MB 6.1 MB +14.31%
Memory resolver[tsconfig resolve] 28.5 KB 25.4 KB +12.39%
Memory resolver[resolve from symlinks] 12.9 MB 11.9 MB +8.24%
Simulation resolver[tsconfig resolve] 4.8 ms 4.5 ms +7.14%
Memory resolver[[single-threaded]resolve with many extensions] 12.1 MB 11.6 MB +4.15%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing refactor/interned-ustr-path (704b58c) with main (ab14d9d)

Open in CodSpeed

Two assertions hardcoded unix separators and failed on windows-latest.
The implementation is correct: UstrPath::new canonicalizes /a/b to \a\b
on Windows by design, so the CI failure was the assertions testing
normalization by accident instead of what they meant to test.

Spelled the expectations out per platform rather than rebuilding them
with join().to_ustr_path(), which would only prove the code agrees with
itself.
stormslowly added a commit to web-infra-dev/rspack that referenced this pull request Jul 31, 2026
rspack-resolver replaces ResolverPath with UstrPath, an 8-byte Copy handle
into the ustr-fxhash global interner. Both crates already depend on
ustr-fxhash 1.0.1, so they share a single interner static.

Minimal adaptation to keep the workspace compiling:

- rspack_paths re-exports UstrPath/ToUstrPath/UstrPathSet, and
  ArcResolverPathSet becomes UstrPathSet
- PackageJson::directory() now returns &Utf8Path, so the DescriptionData
  boundary goes through as_std_path(); DescriptionData keeps its PathBuf
  field because it is cacheable(with = As<PortablePath>) and changing it
  would touch the persistent-cache format
- UstrPath is UTF-8 by construction, so the binding layer uses as_str()
  instead of to_string_lossy()

From<UstrPath> for ArcPath is a placeholder: it materializes an Arc per
conversion, which is exactly the allocation this change exists to remove.
It is here so downstream compiles and can be exercised. Replacing ArcPath
with UstrPath outright is the follow-up that actually banks the win.

The [patch.crates-io] entry pins the resolver to its PR branch rather than
a local path so remote environments can build this too. It is temporary and
tracks rstackjs/rspack-resolver#290; drop it once that lands and is
published.
`UstrPath::new` rewrote Windows paths into canonical form (`\` separators,
collapsed and trailing separators removed, uppercase drive letter) so every
spelling of a path interned to one pointer, matching the folding `Path`'s
component-wise `Hash`/`Eq` provides.

That is right for a cache key but wrong for a general string handle. rspack
stores caller-supplied specifiers in path sets — a loader calling
`addBuildDependency("./build.txt")` puts that literal into `BuildInfo`'s
`build_dependencies` — and the rewrite was observable through the public JS
API, where `./build.txt` came back as `.\build.txt`. It also stripped the
trailing separator from directory entries, so `node_modules/` stopped
matching the `**/node_modules/**` ignore glob in rspack's watcher.

Store the string verbatim. Distinct Windows spellings of one path now intern
to distinct handles and compare unequal; if that dedup is needed later it
belongs in a separate canonicalizing constructor rather than in every
intern. `normalize_windows_separators` and its tests are kept for that.
Interning stores paths verbatim, so on Windows the spellings `Path` treats
as one path — `C:/a/b`, `C:\a\b`, `C:\a\\b`, `C:\a\b\`, `c:\a\b` — become
distinct handles and, under a derived `PartialEq`, compared unequal. Using
them as set or map keys therefore stopped deduplicating, which is the
behaviour `hash_path`'s component-wise `Path::hash` used to provide.

Fold the spellings in the comparison rather than in what gets interned:
`PartialEq` tries the interned pointer first and falls back to `Path`'s
component semantics on Windows, and `Hash` matches it. The stored string is
still exactly what the caller passed, so rspack's caller-supplied specifiers
survive the round trip unchanged.

Both `Hash` branches end in a single `write_u64`. That is required, not
stylistic: `UstrPathSet` is keyed by `ustr::IdentityHasher`, whose `write`
only reads a value when handed exactly 8 bytes and silently yields 0
otherwise, so forwarding `Path::hash` directly would collapse every key into
one bucket with no error. The new tests fold through that hasher rather than
a generic one so a regression there fails loudly.

Also drops a stale `expect(dead_code)` on `is_sep`, which no longer holds now
that the normalizer is unwired — it was breaking `cargo check --all-features`
on every platform and Check Wasm in CI.
…reed

`ustr` never frees an interned string. rspack calls
`resolver_factory.clear_cache()` on every rebuild (via
`plugin_driver.clear_cache`, from both `rebuild.rs` and the scopeguard in
`compiler/mod.rs`), so a dev server rebuilds this cache continuously — and
with a leak-forever interner the strings it drops stay resident, making RSS
climb monotonically and never come back down.

`internment::ArcIntern` refcounts instead: the entry goes away with the last
handle. A new test in `cache.rs` pins that down — it interns a path no other
test uses, checks the cache is holding it, then asserts the refcount falls
back to 1 after `Cache::clear()`.

Notable consequences:

- `UstrPath` is no longer `Copy`. Every handoff is a refcount bump now, and
  the eight sites that relied on `Copy` (`*self`, `.copied()`, `*get_or_init`)
  clone explicitly.

- `as_str()` no longer returns `&'static str`. It cannot: the entry is freed
  once the last handle drops.

- The struct carries its own hash rather than using `ArcIntern`'s. Two reasons.
  `ArcIntern` hashes the pointer, but paths are stored verbatim, so on Windows
  `C:/a/b` and `C:\a\b` are distinct allocations that must still compare and
  hash alike — a pointer hash would break `a == b implies hash(a) == hash(b)`.
  Pointers are also aligned, so the low bits `IdentityHasher` buckets on are
  always zero. Computing it once at construction keeps lookups a single
  `write_u64` and keeps both platforms' semantics correct.

- `IdentityHasher` is now defined here instead of re-exported from `ustr`. The
  local one panics in debug on any write that is not `write_u64`, and folds
  the bytes in release: the ustr version silently yielded 0 for other widths,
  which would collapse a misused map into one bucket with no error.

- `internment`'s arc feature pulls ahash -> getrandom 0.3, which will not build
  for wasm32-unknown-unknown without a named backend. Handled with a
  target-scoped dependency plus `.cargo/config.toml`, so it applies only to
  that target — it is a CI sanity check here, and the shipped wasm artifact
  (wasm32-wasip1-threads) needs none of it. Verified wasm-bindgen stays out of
  the native and wasi dependency trees.
`node_modules_and_package_json_dep_paths_are_memoized` still asserted the
literal `\a\b\node_modules`, left over from when `UstrPath::new` normalized
separators. Interning is verbatim now, so `/a/b` keeps its slashes and only
the joined component gets a `\` — the real value is `/a/b\node_modules`.
Assert `file_name()` and `parent()` instead: pinning the joined string tests
camino's platform behaviour, not the memo, and that is what broke twice.

`getrandom` carries no code reference — it exists only to enable the wasm_js
backend feature for wasm32-unknown-unknown — so cargo-shear reported it as
unused. Listed under package.metadata.cargo-shear.
…terner

`internment::ArcIntern` costs 1004 Ir per intern against `ustr`'s 161, which
callgrind attributes to two things it cannot avoid through its API: its
`DashMap` container re-hashes the string with `ahash` (2.5 full-string hashes
per intern) even though `UstrPath` has already computed an `FxHash` for it,
and the handle has to carry that hash separately because the pointer is not
usable as one, widening every stored path to 16 bytes.

The replacement takes the hash as a parameter and keeps it in the entry
header, so interning hashes once and the handle is back to one pointer. Entry
storage is a single inline allocation rather than a header plus a boxed str.

Concurrency rests on one invariant: every mutation that can drive a refcount
to zero, and every mutation that could raise one back from zero, happens
under that entry's shard lock. `clone` is the one unlocked mutation and can
do neither, since its caller holds a handle. That removes the need for a
resurrection retry protocol at the cost of one uncontended lock per drop,
measured at 2.5 Ir.

Measured on the resolver benchmarks (callgrind, est_cycles vs main):
intern drops to 246 Ir, and the aggregate regression goes from +2.64% to
+0.90% — single-thread +8.45% to +2.78%, [mt]resolve +7.34% to +1.98%. The
remaining gap to `ustr` is the allocation a freed entry needs on its next
use, which is the price of releasing memory at all.

Dropping `internment` also drops ahash and getrandom, so the
wasm32-unknown-unknown backend workaround goes away with it.
Most drops release a temporary handle while the cache still holds the path,
so they never reach zero and never need the table — yet every one of them was
taking a shard lock, serializing resolver threads on a mutex they had no work
to do behind.

The decrement moves out of the lock. Reaching zero is now a *claim* on the
entry rather than ownership of it, so the drop path re-establishes both facts
it used to get for free, in this order:

1. Look the entry up by address, without dereferencing it. A concurrent
   dropper may already have freed it, and removal happens under this lock, so
   absence means someone else won the claim.
2. Re-check the count. A concurrent `intern` may have resurrected the entry
   between the decrement and the lock; the count then names exactly the
   handles that exist, so the dropper walks away.

`intern` is unchanged: it increments under the lock, and doing so to a
zero-count entry is a valid resurrection rather than a race to detect.

`concurrent_zero_crossings_are_sound` targets both windows — eight threads
over a two-key space, every handle dropped immediately. Deleting either check
above makes it fail reliably; it passed three for three before, which is why
the test is written to keep entries at zero rather than merely busy.

Drop falls from 53.5 Ir to 34.7 Ir per call. The contention this removes is
invisible to callgrind, which serializes threads — CodSpeed's multi-threaded
benchmarks are the measurement that can see it.
Freeing an entry the moment its last handle went away made every drop a
potential table mutation, which is why the drop path needed a lock, and then —
once that lock moved off the fast path — a two-window race analysis and a
sanitizer run to trust it. It also meant a `Cache::clear()` threw away entries
the very next resolve would re-intern: 84% of interns were misses, at 246 Ir
each.

The table now owns a reference of its own. A handle's drop can no longer reach
zero, so it is a bare decrement — no lock, no lookup, nothing to race. Entries
leave in `Shard::sweep`, which drops those back down to the table's single
reference, triggered by insertions at a threshold that scales with the shard so
the amortized cost per insertion stays constant.

Soundness collapses back to one line: every 1 -> 2 transition happens under the
shard lock, because the only way to get a handle to a table entry is `intern`,
which holds at least the read lock, and a sweep holds the write lock. `clone`
needs no lock since its caller's handle already puts the count at 2 or more.
Lookups take a read lock rather than an exclusive one, so threads interning
known paths stop queueing behind each other.

Measured against main (callgrind, est_cycles): the aggregate goes from +0.90%
to -0.69%, ahead of even the leak-forever `ustr` baseline at -0.38%. Interning
halves to 122.7 Ir because entries survive `clear_cache()` and the next
iteration hits instead of missing, and allocations land at 80,042 — exactly
`ustr`'s count, 3,002 below main — while the table stays bounded.

`Interner` grows a `Drop` so a dropped instance frees what it owns; an entry a
handle still refers to is leaked rather than freed, since `Interned` does not
borrow from the interner. ASan on Linux reports no errors and no leaks.
A sweep can fire from inside the same `intern` call that inserted a new entry,
and at that moment the entry has no handle — the caller's `Interned` is built
after the sweep returns. What keeps the sweep from collecting it is
`Entry::alloc` starting the count at two: one for the table, one pre-paid for
the handle on its way out.

That is easy to undo. Starting the count at one and incrementing before the
return reads as the more symmetric shape, and it makes `intern` free the very
string it hands back. Nothing failed on that mistake except a heap corruption
trap, far from the line that caused it.

The test watches for the table shrinking to spot which insert swept, then
asserts that insert's own entry survived and was not silently re-allocated. It
also asserts a sweep happened at all, so it cannot pass by never reaching the
case.
`UstrPath` was named after its backing store, and that store has now changed
three times — ustr, then internment, then this crate's own interner. The name
went stale on the first change and is simply wrong after the last one, since no
`ustr` is involved anywhere.

`ResolverPath` names the role instead: the path type this crate hands out.
Whatever the interner does next, that stays true. It is also the name main uses
for the same slot, so downstream keeps the identifier it already has.

Reusing the old name is safe here because the contract carried over. Equality
was raw bytes on unix and `Path` components on Windows; interning stores one
entry per distinct string, so pointer equality means the same thing on unix,
and Windows still folds through `Path`. What genuinely changed — no longer
`Copy`, no `Ord`, `as_str()` borrowing rather than `'static` — all fails to
compile rather than passing quietly.

Mechanical throughout: `ToUstrPath` -> `ToResolverPath`, `UstrPathSet` ->
`ResolverPathSet`, `to_ustr_path` -> `to_resolver_path`, `src/ustr_path.rs` ->
`src/resolver_path.rs`. Comments that named `ustr`'s bin mutex now name the
shard lock that replaced it. `src/interner.rs` still mentions `ustr` where it
means the crate — as a rejected alternative and as the precedent for the shard
count.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant