diff --git a/.gitignore b/.gitignore index 779b0627..be23db63 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules fuzz/Cargo.lock artifacts bindings +optimization-artifacts/ diff --git a/src/cache.rs b/src/cache.rs index 66d92643..0bedf101 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -16,10 +16,12 @@ use futures::future::BoxFuture; use rustc_hash::FxHasher; use tokio::sync::OnceCell as OnceLock; +#[cfg(unix)] +use crate::path::path_parent_unix; use crate::{ context::ResolveContext as Ctx, package_json::{off_to_location, PackageJson}, - path::PathUtil, + path::{path_join_preallocated, PathUtil}, FileMetadata, FileSystem, JSONError, ResolveError, ResolveOptions, TsConfig, }; @@ -59,6 +61,9 @@ impl Cache { if let Some(cache_entry) = self.paths.get((hash, path).borrow() as &dyn CacheKey) { return cache_entry.clone(); } + #[cfg(unix)] + let parent = path_parent_unix(path).map(|p| self.value(p)); + #[cfg(not(unix))] let parent = path.parent().map(|p| self.value(p)); let data = CachedPath(Arc::new(CachedPathImpl::new( hash, @@ -249,6 +254,22 @@ impl CachedPathImpl { } if let Some(parent) = self.parent() { let parent_path = parent.realpath(fs).await?; + // Fast path: when no symlinks were found anywhere up the chain + // (the common case in resolver inputs), `parent_path` is the + // same bytes as `parent.path`. Cache `None` instead of an + // owned copy of `self.path` so the get_or_try_init body + // sidesteps the `normalize_with` allocation and the outer + // `unwrap_or_else` falls through to `self.path` directly. + #[cfg(unix)] + let parent_unchanged = { + use std::os::unix::ffi::OsStrExt; + parent_path.as_os_str().as_bytes() == parent.path.as_os_str().as_bytes() + }; + #[cfg(not(unix))] + let parent_unchanged = parent_path.as_path() == &*parent.path; + if parent_unchanged { + return Ok(None); + } return Ok(Some( parent_path.normalize_with(self.path.strip_prefix(&parent.path).unwrap()), )); @@ -268,7 +289,7 @@ impl CachedPathImpl { cache: &Cache, ctx: &mut Ctx, ) -> Option { - let cached_path = cache.value(&self.path.join(module_name)); + let cached_path = cache.value(&path_join_preallocated(&self.path, module_name)); cached_path .is_dir(&cache.fs, ctx) .await @@ -349,7 +370,7 @@ impl CachedPathImpl { let result = self .package_json .get_or_try_init(|| async { - let package_json_path = self.path.join("package.json"); + let package_json_path = path_join_preallocated(&self.path, "package.json"); let Ok(package_json_string) = fs.read(&package_json_path).await else { return Ok(None); }; @@ -427,7 +448,19 @@ impl Hash for dyn CacheKey + '_ { impl PartialEq for dyn CacheKey + '_ { fn eq(&self, other: &Self) -> bool { - self.tuple().1 == other.tuple().1 + let a = self.tuple().1; + let b = other.tuple().1; + // On Unix, raw byte compare matches `Path::eq` semantics and skips + // the std `Components` iteration cost. Mirrors the hash side already + // bytes-based in `Cache::value`. + #[cfg(unix)] + { + a.as_os_str().as_bytes() == b.as_os_str().as_bytes() + } + #[cfg(not(unix))] + { + a == b + } } } diff --git a/src/file_system.rs b/src/file_system.rs index feaeaa92..2169d46f 100644 --- a/src/file_system.rs +++ b/src/file_system.rs @@ -147,19 +147,26 @@ impl FileSystemOs { #[cfg(not(target_arch = "wasm32"))] #[async_trait::async_trait] impl FileSystem for FileSystemOs { + // The bulk of resolver work is filesystem stat/read operations whose syscall + // cost is microseconds. Calling them through `tokio::fs` forces a + // `spawn_blocking` + semaphore acquire + park/unpark per call, which the + // single-thread bench shows costs ~20M Ir per iteration in tokio runtime + // overhead alone. Use sync std::fs here; the trait still exposes an `async fn` + // so callers can await as before. Other Rust resolvers (swc, oxc) make the + // same tradeoff for the same reason. async fn read(&self, path: &Path) -> io::Result> { cfg_if! { if #[cfg(feature = "yarn_pnp")] { if self.options.enable_pnp { return match VPath::from(path)? { VPath::Zip(info) => self.pnp_lru.read(info.physical_base_path(), info.zip_path), - VPath::Virtual(info) => tokio::fs::read(info.physical_base_path()).await, - VPath::Native(path) => tokio::fs::read(&path).await, + VPath::Virtual(info) => fs::read(info.physical_base_path()), + VPath::Native(path) => fs::read(&path), } } }} - tokio::fs::read(path).await + fs::read(path) } async fn read_to_string(&self, path: &Path) -> io::Result { @@ -168,13 +175,13 @@ impl FileSystem for FileSystemOs { if self.options.enable_pnp { return match VPath::from(path)? { VPath::Zip(info) => self.pnp_lru.read_to_string(info.physical_base_path(), info.zip_path), - VPath::Virtual(info) => tokio::fs::read_to_string(info.physical_base_path()).await, - VPath::Native(path) => tokio::fs::read_to_string(&path).await, + VPath::Virtual(info) => fs::read_to_string(info.physical_base_path()), + VPath::Native(path) => fs::read_to_string(&path), } } } } - tokio::fs::read_to_string(path).await + fs::read_to_string(path) } async fn metadata(&self, path: &Path) -> io::Result { @@ -186,24 +193,18 @@ impl FileSystem for FileSystemOs { .pnp_lru .file_type(info.physical_base_path(), info.zip_path) .map(FileMetadata::from), - VPath::Virtual(info) => { - tokio::fs::metadata(info.physical_base_path()) - .await - .map(FileMetadata::from) - } - VPath::Native(path) => tokio::fs::metadata(path).await.map(FileMetadata::from), + VPath::Virtual(info) => fs::metadata(info.physical_base_path()).map(FileMetadata::from), + VPath::Native(path) => fs::metadata(path).map(FileMetadata::from), } } } } - tokio::fs::metadata(path).await.map(FileMetadata::from) + fs::metadata(path).map(FileMetadata::from) } async fn symlink_metadata(&self, path: &Path) -> io::Result { - tokio::fs::symlink_metadata(path) - .await - .map(FileMetadata::from) + fs::symlink_metadata(path).map(FileMetadata::from) } async fn canonicalize(&self, path: &Path) -> io::Result { diff --git a/src/lib.rs b/src/lib.rs index bc0bd309..f913760e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,6 +97,50 @@ use crate::{ type ResolveResult = Result, ResolveError>; +/// Classifies a specifier into the dispatch bucket used by +/// [`ResolverGeneric::require_without_parse`]. +/// +/// Avoids the std `Path::Components` walk for the dispatch decision, which +/// fires on every resolve. The bench `single-thread` case triggers this +/// ~880 times per iteration with all-cold caches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpecifierKind { + Absolute, + Relative, + Hash, + Other, +} + +#[inline] +fn specifier_kind(specifier: &str) -> SpecifierKind { + let bytes = specifier.as_bytes(); + match bytes { + [] => SpecifierKind::Other, + [b'/', ..] => SpecifierKind::Absolute, + #[cfg(windows)] + [b'\\', ..] => SpecifierKind::Absolute, + [b'.', b'.'] | [b'.', b'.', b'/', ..] => SpecifierKind::Relative, + #[cfg(windows)] + [b'.', b'.', b'\\', ..] => SpecifierKind::Relative, + [b'.'] | [b'.', b'/', ..] => SpecifierKind::Relative, + #[cfg(windows)] + [b'.', b'\\', ..] => SpecifierKind::Relative, + [b'#', ..] => SpecifierKind::Hash, + _ => { + #[cfg(windows)] + { + if matches!( + Path::new(specifier).components().next(), + Some(Component::Prefix(_)) + ) { + return SpecifierKind::Absolute; + } + } + SpecifierKind::Other + } + } +} + /// Context returned from the [Resolver::resolve_with_context] API #[derive(Debug, Default, Clone)] pub struct ResolveContext { @@ -336,20 +380,14 @@ impl ResolverGeneric { return Ok(path); } - let result = match Path::new(specifier).components().next() { + let result = match specifier_kind(specifier) { // 2. If X begins with '/' - Some(Component::RootDir | Component::Prefix(_)) => { - self.require_absolute(cached_path, specifier, ctx).await - } - // 3. If X begins with './' or '/' or '../' - Some(Component::CurDir | Component::ParentDir) => { - self.require_relative(cached_path, specifier, ctx).await - } + SpecifierKind::Absolute => self.require_absolute(cached_path, specifier, ctx).await, + // 3. If X begins with './' or '../' (or is "." / "..") + SpecifierKind::Relative => self.require_relative(cached_path, specifier, ctx).await, // 4. If X begins with '#' - Some(Component::Normal(_)) if specifier.as_bytes()[0] == b'#' => { - self.require_hash(cached_path, specifier, ctx).await - } - _ => { + SpecifierKind::Hash => self.require_hash(cached_path, specifier, ctx).await, + SpecifierKind::Other => { // 1. If X is a core module, // a. return the core module // b. STOP @@ -1096,9 +1134,18 @@ impl ResolverGeneric { // 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH, // `package.json` "exports", ["node", "require"]) defined in the ESM resolver. // Note: The subpath is not prepended with a dot on purpose + // Skip the `format!` alloc when subpath is empty (the common bare-specifier + // case like `@scope/pkg`). When non-empty, prepend once before the loop. + let prefixed_owned; + let prefixed: &str = if subpath.is_empty() { + "." + } else { + prefixed_owned = format!(".{subpath}"); + &prefixed_owned + }; for exports in package_json.exports_fields(&self.options.exports_fields) { if let Some(path) = self - .package_exports_resolve(cached_path.path(), &format!(".{subpath}"), exports, ctx) + .package_exports_resolve(cached_path.path(), prefixed, exports, ctx) .await? { // 6. RESOLVE_ESM_MATCH(MATCH) @@ -1136,9 +1183,16 @@ impl ResolverGeneric { let package_url = package_json.directory(); // Note: The subpath is not prepended with a dot on purpose // because `package_exports_resolve` matches subpath without the leading dot. + let prefixed_owned; + let prefixed: &str = if subpath.is_empty() { + "." + } else { + prefixed_owned = format!(".{subpath}"); + &prefixed_owned + }; for exports in package_json.exports_fields(&self.options.exports_fields) { if let Some(cached_path) = self - .package_exports_resolve(package_url, &format!(".{subpath}"), exports, ctx) + .package_exports_resolve(package_url, prefixed, exports, ctx) .await? { // 6. RESOLVE_ESM_MATCH(MATCH) @@ -1707,9 +1761,16 @@ impl ResolverGeneric { .package_json(&self.cache.fs, &self.options, ctx) .await? { + let prefixed_owned; + let prefixed: &str = if subpath.is_empty() { + "." + } else { + prefixed_owned = format!(".{subpath}"); + &prefixed_owned + }; for exports in package_json.exports_fields(&self.options.exports_fields) { if let Some(path) = self - .package_exports_resolve(cached_path.path(), &format!(".{subpath}"), exports, ctx) + .package_exports_resolve(cached_path.path(), prefixed, exports, ctx) .await? { return Ok(Some(path)); @@ -1727,9 +1788,15 @@ impl ResolverGeneric { } } } - let subpath = format!(".{subpath}"); + let subpath_owned; + let subpath: &str = if subpath.is_empty() { + "." + } else { + subpath_owned = format!(".{subpath}"); + &subpath_owned + }; ctx.with_fully_specified(false); - self.require(&cached_path, &subpath, ctx).await.map(Some) + self.require(&cached_path, subpath, ctx).await.map(Some) } /// PACKAGE_EXPORTS_RESOLVE(packageURL, subpath, exports, conditions) diff --git a/src/path.rs b/src/path.rs index 5034533d..3e35958b 100644 --- a/src/path.rs +++ b/src/path.rs @@ -11,6 +11,50 @@ pub fn path_to_str(path: &Path) -> &str { path.to_str().expect("path should be UTF-8") } +/// `Path::join` equivalent that allocates the worst-case capacity up front so +/// the inner `Vec` never has to regrow when the separator + segment is pushed. +/// Hot on parent walks that repeatedly join names like `node_modules` and +/// `package.json` to a cached directory path. +#[inline] +pub fn path_join_preallocated(base: &Path, sub: &str) -> PathBuf { + let mut buf = PathBuf::with_capacity(base.as_os_str().len() + sub.len() + 1); + buf.push(base); + buf.push(sub); + buf +} + +/// Byte-level [`Path::parent`] for unix targets. +/// +/// `std::path::Path::parent` builds a `Components` iterator and walks one step +/// back, which the bench shows costs ~2M Ir per `resolver/single-thread` +/// iteration (one call per cache miss in [`crate::cache::Cache::value`]). +/// Operating on raw bytes — like the existing `Cache::value` hash and the +/// `CachedPath` eq path — sidesteps that. +/// +/// Behavior mirrors std's impl: drops trailing separators, splits at the last +/// remaining separator, collapses repeated separators before the split point, +/// and returns `None` for an empty path or a path whose only content is one +/// or more separators (i.e. root). +#[cfg(unix)] +#[inline] +pub fn path_parent_unix(path: &Path) -> Option<&Path> { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + + let bytes = path.as_os_str().as_bytes(); + let last_non_slash = bytes.iter().rposition(|&b| b != b'/')?; + let trimmed = &bytes[..=last_non_slash]; + let parent_end = trimmed + .iter() + .rposition(|&b| b == b'/') + .map_or(0, |slash_pos| { + bytes[..slash_pos] + .iter() + .rposition(|&b| b != b'/') + .map_or_else(|| usize::from(bytes.first() == Some(&b'/')), |p| p + 1) + }); + Some(Path::new(OsStr::from_bytes(&bytes[..parent_end]))) +} + /// Extension trait to add path normalization to std's [`Path`]. pub trait PathUtil { /// Normalize this path without performing I/O. @@ -77,7 +121,10 @@ impl PathUtil for Path { return subpath.to_path_buf(); } - let mut ret = self.to_path_buf(); + // Pre-size to the worst-case length so the loop's pushes can never grow + // the inner Vec. `+1` covers the separator inserted by `PathBuf::push`. + let mut ret = PathBuf::with_capacity(self.as_os_str().len() + subpath.as_os_str().len() + 1); + ret.push(self); for component in std::iter::once(head).chain(components) { match component { Component::CurDir => {} @@ -129,6 +176,38 @@ async fn is_invalid_exports_target() { assert!(!Path::new("/").is_invalid_exports_target()); } +#[cfg(unix)] +#[tokio::test] +async fn path_parent_unix_matches_std() { + let cases = [ + "/foo/bar", + "/foo", + "/", + "", + "foo", + "foo/bar", + ".", + "..", + "./foo", + "../foo", + "/foo/", + "/foo/bar/", + "//foo/bar", + "foo//bar", + "/a/b/c/d/e", + "/", + "//", + ]; + for case in cases { + let p = Path::new(case); + assert_eq!( + path_parent_unix(p), + p.parent(), + "case={case:?} diverged from std::Path::parent" + ); + } +} + #[tokio::test] async fn normalize() { assert_eq!(Path::new("/foo/.././foo/").normalize(), Path::new("/foo"));