Skip to content

Commit 8cefe78

Browse files
committed
perf: byte-level path handling on Unix to bypass Components iterator
On Unix, an OsStr is raw bytes and '/', '.', '..' are always single-byte ASCII, so the heavy std::path::Components state machine (Windows-prefix detection, Component enum construction, double-ended-iter bookkeeping) is unnecessary for the resolver's hot paths. Changes: - src/path.rs: unix_normalize and unix_normalize_with replace the Components-based PathUtil::normalize / normalize_with on Unix. - src/cache.rs: byte-level parent_path replaces Path::parent in Cache::value; join_last_segment replaces Path::strip_prefix + normalize_with in realpath_uncached, since parent.path is already a strict byte prefix of self.path. Note: an earlier draft of this PR also rewrote require_without_parse to dispatch on the specifier head via a byte table. That hunk was dropped (see #246) because the maintenance cost of keeping a parallel impl of Path::components in sync with std outweighed the small isolated win. The remaining changes here cover the dominant Ir cost paths. 138/138 non-PNP unit tests pass. The 6 PNP tests already fail on main without these changes.
1 parent 080188f commit 8cefe78

2 files changed

Lines changed: 259 additions & 49 deletions

File tree

src/cache.rs

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ use futures::future::BoxFuture;
1414
use rustc_hash::FxHasher;
1515
use tokio::sync::OnceCell as OnceLock;
1616

17+
#[cfg(not(unix))]
18+
use crate::path::PathUtil;
1719
use crate::{
1820
context::ResolveContext as Ctx,
1921
package_json::{off_to_location, PackageJson},
20-
path::PathUtil,
2122
resolver_path::{hash_path, ResolverPath},
2223
FileMetadata, FileSystem, JSONError, ResolveError, ResolveOptions, TsConfig,
2324
};
@@ -48,7 +49,12 @@ impl<Fs: Send + Sync + FileSystem> Cache<Fs> {
4849
if let Some(cache_entry) = self.paths.get((hash, path).borrow() as &dyn CacheKey) {
4950
return cache_entry.clone();
5051
}
51-
let parent = path.parent().map(|p| self.value(p));
52+
// Why: Cache::value is the recursive parent-walk root. `Path::parent` goes
53+
// through `Components::next_back` / `parse_next_component_back`, which
54+
// callgrind showed as the single largest non-allocator non-simd-json
55+
// hotspot. On Unix the separator is always single-byte ASCII, so an
56+
// `rposition(/)` over raw `OsStr` bytes is equivalent and far cheaper.
57+
let parent = parent_path(path).map(|p| self.value(p));
5258
let data = CachedPath(Arc::new(CachedPathImpl::new(
5359
hash,
5460
path.to_path_buf().into_boxed_path(),
@@ -247,9 +253,16 @@ impl CachedPathImpl {
247253
}
248254
if let Some(parent) = self.parent() {
249255
let parent_path = parent.realpath(fs).await?;
250-
return Ok(Some(
251-
parent_path.normalize_with(self.path.strip_prefix(&parent.path).unwrap()),
252-
));
256+
// Why: parent's `path` is a strict byte prefix of `self.path`
257+
// (parents are produced by the byte-level `parent_path`), so
258+
// `strip_prefix` is the path between them. Skipping
259+
// `Path::strip_prefix` + `normalize_with` avoids another
260+
// `Components` walk per realpath miss.
261+
return Ok(Some(join_last_segment(
262+
&parent_path,
263+
&self.path,
264+
&parent.path,
265+
)));
253266
}
254267
Ok(None)
255268
})
@@ -416,6 +429,78 @@ impl CachedPathImpl {
416429
}
417430
}
418431

432+
/// Join `base` with the last segment of `child`, where `child_parent` is the
433+
/// `parent_path()` of `child` (i.e. a strict byte prefix of `child`). Used by
434+
/// `realpath_uncached` to avoid walking `Path::strip_prefix` + `normalize_with`
435+
/// when we already know the suffix is a single normal segment.
436+
#[cfg(unix)]
437+
fn join_last_segment(base: &Path, child: &Path, child_parent: &Path) -> PathBuf {
438+
use std::{
439+
ffi::OsString,
440+
os::unix::ffi::{OsStrExt, OsStringExt},
441+
};
442+
443+
let child_bytes = child.as_os_str().as_bytes();
444+
let parent_len = child_parent.as_os_str().len();
445+
446+
// Skip the `/` between parent and the trailing segment when applicable.
447+
let suffix_start = if parent_len < child_bytes.len() && child_bytes[parent_len] == b'/' {
448+
parent_len + 1
449+
} else {
450+
parent_len
451+
};
452+
let suffix = &child_bytes[suffix_start..];
453+
454+
let base_bytes = base.as_os_str().as_bytes();
455+
let mut out = Vec::with_capacity(base_bytes.len() + 1 + suffix.len());
456+
out.extend_from_slice(base_bytes);
457+
458+
if !suffix.is_empty() {
459+
if !out.is_empty() && *out.last().unwrap() != b'/' {
460+
out.push(b'/');
461+
}
462+
out.extend_from_slice(suffix);
463+
}
464+
465+
PathBuf::from(OsString::from_vec(out))
466+
}
467+
468+
#[cfg(not(unix))]
469+
fn join_last_segment(base: &Path, child: &Path, child_parent: &Path) -> PathBuf {
470+
use crate::path::PathUtil;
471+
base.normalize_with(child.strip_prefix(child_parent).unwrap())
472+
}
473+
474+
/// Byte-level parent lookup for Unix. See `Cache::value` for why.
475+
#[cfg(unix)]
476+
fn parent_path(path: &Path) -> Option<&Path> {
477+
use std::os::unix::ffi::OsStrExt;
478+
let bytes = path.as_os_str().as_bytes();
479+
// Trim a trailing `/` that isn't itself the root, mirroring std's
480+
// `Components` ignoring redundant separators.
481+
let trimmed_len = match bytes {
482+
[.., b'/'] if bytes.len() > 1 => bytes.len() - 1,
483+
_ => bytes.len(),
484+
};
485+
let trimmed = &bytes[..trimmed_len];
486+
let last_slash = trimmed.iter().rposition(|&b| b == b'/')?;
487+
if last_slash == 0 {
488+
// Parent is the root "/".
489+
if bytes.len() == 1 {
490+
// Path was "/", no parent.
491+
return None;
492+
}
493+
return Some(Path::new(std::ffi::OsStr::from_bytes(&bytes[..1])));
494+
}
495+
Some(Path::new(std::ffi::OsStr::from_bytes(&bytes[..last_slash])))
496+
}
497+
498+
#[cfg(not(unix))]
499+
#[inline]
500+
fn parent_path(path: &Path) -> Option<&Path> {
501+
path.parent()
502+
}
503+
419504
/// Memoized cache key, code adapted from <https://stackoverflow.com/a/50478038>.
420505
trait CacheKey {
421506
fn tuple(&self) -> (u64, &Path);

src/path.rs

Lines changed: 169 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,65 +35,89 @@ pub trait PathUtil {
3535
impl PathUtil for Path {
3636
// https://github.com/parcel-bundler/parcel/blob/e0b99c2a42e9109a9ecbd6f537844a1b33e7faf5/packages/utils/node-resolver-rs/src/path.rs#L7
3737
fn normalize(&self) -> PathBuf {
38-
let mut components = self.components().peekable();
39-
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek() {
40-
let buf = PathBuf::from(c.as_os_str());
41-
components.next();
42-
buf
43-
} else {
44-
PathBuf::new()
45-
};
46-
47-
for component in components {
48-
match component {
49-
Component::Prefix(..) => unreachable!("Path {:?}", self),
50-
Component::RootDir => {
51-
ret.push(component.as_os_str());
52-
}
53-
Component::CurDir => {}
54-
Component::ParentDir => {
55-
ret.pop();
56-
}
57-
Component::Normal(c) => {
58-
ret.push(c);
38+
// Why: On Unix, an `OsStr` is raw bytes and `/`, `.` are always single-byte ASCII
39+
// regardless of UTF-8 content in segments. Iterating bytes directly skips
40+
// the heavy `Components` state machine (`parse_next_component_back`,
41+
// `Component::PartialEq`, double-ended iter bookkeeping) that dominated
42+
// ~3% of the resolver's instructions in callgrind.
43+
#[cfg(unix)]
44+
{
45+
unix_normalize(self)
46+
}
47+
#[cfg(not(unix))]
48+
{
49+
let mut components = self.components().peekable();
50+
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek() {
51+
let buf = PathBuf::from(c.as_os_str());
52+
components.next();
53+
buf
54+
} else {
55+
PathBuf::new()
56+
};
57+
58+
for component in components {
59+
match component {
60+
Component::Prefix(..) => unreachable!("Path {:?}", self),
61+
Component::RootDir => {
62+
ret.push(component.as_os_str());
63+
}
64+
Component::CurDir => {}
65+
Component::ParentDir => {
66+
ret.pop();
67+
}
68+
Component::Normal(c) => {
69+
ret.push(c);
70+
}
5971
}
6072
}
61-
}
6273

63-
ret
74+
ret
75+
}
6476
}
6577

6678
// https://github.com/parcel-bundler/parcel/blob/e0b99c2a42e9109a9ecbd6f537844a1b33e7faf5/packages/utils/node-resolver-rs/src/path.rs#L37
6779
fn normalize_with<B: AsRef<Self>>(&self, subpath: B) -> PathBuf {
6880
let subpath = subpath.as_ref();
6981

70-
let mut components = subpath.components();
82+
// Why: callgrind showed `Components::next` + `parse_next_component_back` +
83+
// `Component::PartialEq` totalling ~5% of Ir, almost all driven from
84+
// `normalize_with` calls in the resolver hot path. On Unix the separator
85+
// and `.`/`..` markers are guaranteed single-byte ASCII, so a byte-level
86+
// pass produces identical output without the iterator overhead.
87+
#[cfg(unix)]
88+
{
89+
unix_normalize_with(self, subpath)
90+
}
91+
#[cfg(not(unix))]
92+
{
93+
let mut components = subpath.components();
7194

72-
let Some(head) = components.next() else {
73-
return subpath.to_path_buf();
74-
};
95+
let Some(head) = components.next() else {
96+
return subpath.to_path_buf();
97+
};
7598

76-
if matches!(head, Component::Prefix(..) | Component::RootDir) {
77-
return subpath.to_path_buf();
78-
}
99+
if matches!(head, Component::Prefix(..) | Component::RootDir) {
100+
return subpath.to_path_buf();
101+
}
79102

80-
let mut ret = self.to_path_buf();
81-
for component in std::iter::once(head).chain(components) {
82-
match component {
83-
Component::CurDir => {}
84-
Component::ParentDir => {
85-
ret.pop();
86-
}
87-
Component::Normal(c) => {
88-
ret.push(c);
89-
}
90-
Component::Prefix(..) | Component::RootDir => {
91-
unreachable!("Path {:?} Subpath {:?}", self, subpath)
103+
let mut ret = self.to_path_buf();
104+
for component in std::iter::once(head).chain(components) {
105+
match component {
106+
Component::CurDir => {}
107+
Component::ParentDir => {
108+
ret.pop();
109+
}
110+
Component::Normal(c) => {
111+
ret.push(c);
112+
}
113+
Component::Prefix(..) | Component::RootDir => {
114+
unreachable!("Path {:?} Subpath {:?}", self, subpath)
115+
}
92116
}
93117
}
94-
}
95118

96-
ret
119+
ret
120+
}
97121
}
98122

99123
fn is_invalid_exports_target(&self) -> bool {
@@ -106,6 +130,107 @@ impl PathUtil for Path {
106130
}
107131
}
108132

133+
/// Byte-level `normalize` for Unix. See [`PathUtil::normalize`] for why.
134+
#[cfg(unix)]
135+
fn unix_normalize(path: &Path) -> PathBuf {
136+
use std::{
137+
ffi::OsString,
138+
os::unix::ffi::{OsStrExt, OsStringExt},
139+
};
140+
141+
let bytes = path.as_os_str().as_bytes();
142+
let leading_slash = bytes.first() == Some(&b'/');
143+
144+
// Worst-case capacity: original length + a trailing slash placeholder.
145+
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
146+
if leading_slash {
147+
out.push(b'/');
148+
}
149+
150+
// Track segment offsets we've written into `out` so `..` can pop in O(1)
151+
// instead of rescanning `out` byte-by-byte.
152+
let mut starts: Vec<usize> = Vec::new();
153+
154+
for seg in bytes.split(|&b| b == b'/') {
155+
match seg {
156+
b"" | b"." => {}
157+
b".." => {
158+
if let Some(start) = starts.pop() {
159+
// Trim trailing `/` left over from a previous segment.
160+
out.truncate(start.saturating_sub(usize::from(start > usize::from(leading_slash))));
161+
}
162+
}
163+
normal => {
164+
// Insert a separator before every segment except the very first one
165+
// when there is no leading slash.
166+
if out.len() > usize::from(leading_slash) {
167+
out.push(b'/');
168+
}
169+
starts.push(out.len());
170+
out.extend_from_slice(normal);
171+
}
172+
}
173+
}
174+
175+
if out.is_empty() {
176+
return PathBuf::new();
177+
}
178+
179+
PathBuf::from(OsString::from_vec(out))
180+
}
181+
182+
/// Byte-level `normalize_with` for Unix. See [`PathUtil::normalize_with`] for why.
183+
#[cfg(unix)]
184+
fn unix_normalize_with(base: &Path, subpath: &Path) -> PathBuf {
185+
use std::{
186+
ffi::OsString,
187+
os::unix::ffi::{OsStrExt, OsStringExt},
188+
};
189+
190+
let sub_bytes = subpath.as_os_str().as_bytes();
191+
192+
if sub_bytes.is_empty() {
193+
return subpath.to_path_buf();
194+
}
195+
196+
// Absolute subpath short-circuits to subpath, matching the std behavior of
197+
// `PathBuf::push` and the original Components-based implementation.
198+
if sub_bytes[0] == b'/' {
199+
return subpath.to_path_buf();
200+
}
201+
202+
let base_bytes = base.as_os_str().as_bytes();
203+
let mut out: Vec<u8> = Vec::with_capacity(base_bytes.len() + 1 + sub_bytes.len());
204+
out.extend_from_slice(base_bytes);
205+
206+
for seg in sub_bytes.split(|&b| b == b'/') {
207+
match seg {
208+
b"" | b"." => {}
209+
b".." => {
210+
// Pop the trailing segment from `out` without rescanning whole bytes
211+
// ahead of time: `rposition` walks from the end.
212+
if let Some(slash) = out.iter().rposition(|&b| b == b'/') {
213+
if slash == 0 {
214+
out.truncate(1);
215+
} else {
216+
out.truncate(slash);
217+
}
218+
} else {
219+
out.clear();
220+
}
221+
}
222+
normal => {
223+
if !out.is_empty() && *out.last().unwrap() != b'/' {
224+
out.push(b'/');
225+
}
226+
out.extend_from_slice(normal);
227+
}
228+
}
229+
}
230+
231+
PathBuf::from(OsString::from_vec(out))
232+
}
233+
109234
// https://github.com/webpack/enhanced-resolve/blob/main/test/path.test.js
110235
#[tokio::test]
111236
async fn is_invalid_exports_target() {

0 commit comments

Comments
 (0)