diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index 5f96e0f..dffa70a 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -164,12 +164,16 @@ This checkpoint is historical and predates the breaking design: the final API de 7. Consider de-monomorphizing `relative` for consumer code size. Treat this as compile-time/binary-size work unless runtime evidence says otherwise. 8. Attempt explicit SIMD only if instruction and profile data still show separator or common-prefix scanning as a leading cost. Completed for the remaining case-sensitive common-prefix loop by `0da5a61`; existing `memchr` scans and Windows comparison stay unchanged. -## Remaining consumer-backed directions +## Remaining library directions (corrected) -- Keep the Windows native-separator-aware scan under the native CI allocation and correctness gates. The current snapshots prove a zero-allocation canonical descendant and one-allocation ordinary final `String`; the drive, UNC, verbatim, device, mixed-separator, invalid-wide, and exact-spelling matrix must remain intact when the scan changes. -- Migrate Rolldown's known-UTF-8 `stabilize_id` and sourcemap endpoints to `relative(base).into_owned().into_slash()` without changing their distinct empty-result handling. The target is one reusable final `String` buffer with no second result allocation. Do not use a lossy-named operation, and reconsider a public fused method only if an end-to-end final-container benchmark later shows that ordinary composition cannot meet the target. -- Consider making normalized module IDs an explicit Rolldown invariant. All 4,901 observed normalization calls were clean borrowed no-ops, so validating once at an ingestion boundary could remove repeated scans more effectively than another SugarPath classifier branch. This is a Rolldown data-model change, not a library fast path. -- Revisit direct ArcStr initialization only with a caller-owned output sink or exact-length writer benchmark. ArcStr cannot adopt the current `String` allocation, and a SugarPath dependency on ArcStr would be inappropriate; the integration boundary must stay generic and prove that its callback or writer overhead is lower than the saved copy. +Clean-path fast cases (0-alloc normalize, absolute absolutize, borrowable descendant `relative`, clean owned normalize/slash, owned-cwd `absolutize_with` reuse) are done and gated. **Slow path means the full general algorithm, not a rare edge:** dirty normalize, non-borrowable `relative` (upward, unequal parents, complex roots), dirty join pipelines, and invalid-encoding rebuilds. Those are the default path for many inputs and remain the **in-crate optimization target**. + +- **Recorded unequal leading-parent relative improvement:** pure relative pairs that miss the equal-parent lexical hit now resolve both sides against one ambient cwd as normal-component stacks and allocate only the result buffer (allocation hard gate: 5 alloc / multi-realloc → 1 / 0 for shallow and p99 unequal rows). Absolute/absolute and equal-parent hits still return earlier and are untaxed. Same-machine Criterion on Apple ARM64 with `cached_current_dir` saw about 50% wall-time reduction on the shallow unequal workload and about 35% on the p99 unequal workload (directional local evidence; continuous CodSpeed remains Linux corroboration). +- Still open on the general path: dirty join→PathBuf/String intermediate costs (2–3 alloc), invalid-encoding absolute relative rebuilds, and any further cheapening of dirty `normalize_inner` without regressing clean 0-alloc gates. +- Keep the Windows native-separator-aware scan under native CI allocation and correctness gates (drive, UNC, verbatim, device, mixed-separator, invalid-wide, exact spelling). +- De-monomorphize `relative` only with binary-size / `llvm-lines` evidence. More SIMD only with profile proof. No public fused relative-to-string API and no ArcStr dependency in this crate by default. + +Consumer adoption and app-level invariants are **out of this library backlog**. ## Boshen plan applicability audit diff --git a/benchmarks/allocations/x86_64-pc-windows-msvc.snap b/benchmarks/allocations/x86_64-pc-windows-msvc.snap index 8fe02f1..1fa9963 100644 --- a/benchmarks/allocations/x86_64-pc-windows-msvc.snap +++ b/benchmarks/allocations/x86_64-pc-windows-msvc.snap @@ -48,9 +48,9 @@ Each row measures one operation after allocation-capable setup and an untracked | relative / relative inputs -> relative path | 1 | 0 | 0 | | relative / dotted relative inputs -> relative path | 1 | 0 | 0 | | relative / equal leading-parent inputs -> relative path | 1 | 0 | 0 | -| relative / unequal leading-parent inputs -> relative path | 5 | 0 | 2 | +| relative / unequal leading-parent inputs -> relative path | 1 | 0 | 0 | | relative / p99-depth equal leading-parent inputs -> relative path | 1 | 0 | 0 | -| relative / p99-depth unequal leading-parent inputs -> relative path | 5 | 0 | 2 | +| relative / p99-depth unequal leading-parent inputs -> relative path | 1 | 0 | 0 | | relative / current-directory spellings -> relative path | 0 | 0 | 0 | | relative / dotted slow path -> relative path | 4 | 0 | 0 | | relative / invalid-encoding absolute target -> relative path (setup excluded) | 3 | 0 | 0 | diff --git a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap index 849e941..24c8c9b 100644 --- a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap +++ b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap @@ -45,9 +45,9 @@ Each row measures one operation after allocation-capable setup and an untracked | relative / relative inputs -> relative path | 1 | 0 | 0 | | relative / dotted relative inputs -> relative path | 1 | 0 | 0 | | relative / equal leading-parent inputs -> relative path | 1 | 0 | 0 | -| relative / unequal leading-parent inputs -> relative path | 5 | 0 | 4 | +| relative / unequal leading-parent inputs -> relative path | 1 | 0 | 0 | | relative / p99-depth equal leading-parent inputs -> relative path | 1 | 0 | 0 | -| relative / p99-depth unequal leading-parent inputs -> relative path | 5 | 0 | 6 | +| relative / p99-depth unequal leading-parent inputs -> relative path | 1 | 0 | 0 | | relative / current-directory spellings -> relative path | 0 | 0 | 0 | | relative / dotted slow path -> relative path | 1 | 0 | 0 | | relative / invalid-encoding absolute target -> relative path (setup excluded) | 3 | 0 | 3 | diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 0944c39..af00b48 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -118,18 +118,61 @@ fn try_relative_lexically(target: &Path, base: &Path) -> Option { let target = collect_lexical_normals(target, target_shape); let base = collect_lexical_normals(base, base_shape); + relative_from_normal_stacks(&base, &target) +} + +/// Walk an absolute path into its normal-component stack under the root (and +/// Windows prefix). CurDir is ignored; ParentDir pops; RootDir clears normals. +/// Pure relative inputs share this stack when resolved against one cwd. +fn absolute_normal_stack<'a>(path: &'a Path) -> Option> { + if !path.is_absolute() { + return None; + } - let common_len = target + let mut stack = OsStrVec::with_capacity(16); + for component in path.components() { + match component { + Component::Prefix(_) => { + // Pure relative inputs inherit the cwd prefix implicitly. Different + // prefixes cannot arise when both sides resolve against the same cwd. + } + Component::RootDir => stack.clear(), + Component::CurDir => {} + Component::ParentDir => { + let _ = stack.pop(); + } + Component::Normal(normal) => stack.push(normal), + } + } + Some(stack) +} + +fn apply_relative_shape_to_stack<'a>( + cwd_stack: &OsStrVec<'a>, + shape: LexicalRelativeShape, + relative: &'a Path, +) -> OsStrVec<'a> { + let kept = cwd_stack.len().saturating_sub(shape.unresolved_parents); + let mut stack = OsStrVec::with_capacity(kept + shape.surviving_normals); + stack.extend_from_slice(&cwd_stack[..kept]); + for normal in collect_lexical_normals(relative, shape) { + stack.push(normal); + } + stack +} + +fn relative_from_normal_stacks(base: &OsStrVec<'_>, target: &OsStrVec<'_>) -> Option { + let common_len = base .iter() - .zip(&base) - .take_while(|(target, base)| { + .zip(target.iter()) + .take_while(|(from, to)| { #[cfg(target_family = "windows")] { - target.eq_ignore_ascii_case(base) + from.eq_ignore_ascii_case(to) } #[cfg(not(target_family = "windows"))] { - target == base + from == to } }) .count(); @@ -137,13 +180,15 @@ fn try_relative_lexically(target: &Path, base: &Path) -> Option { let up_len = base.len() - common_len; let target_suffix = &target[common_len..]; #[cfg(target_family = "windows")] - if up_len == 0 - && target_suffix.first().is_some_and(|component| { - !windows_standalone_relative_bytes_are_representable(component.as_encoded_bytes()) - }) + if target_suffix.iter().any(|component| memchr(b'/', component.as_encoded_bytes()).is_some()) + || (up_len == 0 + && target_suffix.first().is_some_and(|component| { + !windows_standalone_relative_bytes_are_representable(component.as_encoded_bytes()) + })) { return None; } + let component_count = up_len + target_suffix.len(); let capacity = up_len * 2 + target_suffix.iter().map(|component| component.len()).sum::() @@ -160,17 +205,42 @@ fn try_relative_lexically(target: &Path, base: &Path) -> Option { Some(PathBuf::from(relative)) } #[cfg(not(target_family = "windows"))] - let mut relative = PathBuf::with_capacity(capacity); - #[cfg(not(target_family = "windows"))] - for _ in 0..up_len { - relative.push(Component::ParentDir); - } - #[cfg(not(target_family = "windows"))] - for component in target_suffix { - relative.push(component); + { + let mut relative = PathBuf::with_capacity(capacity); + for _ in 0..up_len { + relative.push(Component::ParentDir); + } + for component in target_suffix { + relative.push(*component); + } + Some(relative) } - #[cfg(not(target_family = "windows"))] - Some(relative) +} + +/// When both inputs are pure relative but do not share a leading-parent count +/// (or other cwd-independent shape), resolve them against one absolute cwd as +/// normal-component stacks and build a single relative result. Avoids cloning +/// cwd twice and allocating two intermediate absolute `PathBuf`s. +/// +/// Callers that already hold shapes should use +/// [`relative_both_relative_via_cwd_with_shapes`] so each path is classified once. +fn try_relative_both_relative_via_cwd(target: &Path, base: &Path, cwd: &Path) -> Option { + let target_shape = classify_lexical_relative(target)?; + let base_shape = classify_lexical_relative(base)?; + relative_both_relative_via_cwd_with_shapes(target, target_shape, base, base_shape, cwd) +} + +fn relative_both_relative_via_cwd_with_shapes( + target: &Path, + target_shape: LexicalRelativeShape, + base: &Path, + base_shape: LexicalRelativeShape, + cwd: &Path, +) -> Option { + let cwd_stack = absolute_normal_stack(cwd)?; + let base_resolved = apply_relative_shape_to_stack(&cwd_stack, base_shape, base); + let target_resolved = apply_relative_shape_to_stack(&cwd_stack, target_shape, target); + relative_from_normal_stacks(&base_resolved, &target_resolved) } #[cfg(target_family = "windows")] @@ -823,7 +893,30 @@ fn try_relative_outcome<'a>( return Ok(outcome); } - // Slow path: avoid current_dir() for already-absolute paths. + // Pure lexical relative pairs only (no prefix/root). Windows drive-relative + // inputs are `!has_root()` but carry a Prefix — they must keep try_absolutize + // so ambient relative uses per-drive `std::path::absolute`, not absolutize_with + // against the process cwd. Classify once; reuse shapes for the stack resolve. + if let (Some(target_shape), Some(base_shape)) = + (classify_lexical_relative(target_path), classify_lexical_relative(base_path)) + { + let cwd = try_get_current_dir()?; + if let Some(relative) = relative_both_relative_via_cwd_with_shapes( + target_path, + target_shape, + base_path, + base_shape, + cwd.as_ref(), + ) { + return Ok(RelativeOutcome::Native(relative)); + } + let base = base_path.absolutize_with(cwd.as_ref()).into_owned(); + let target = target_path.absolutize_with(cwd.as_ref()).into_owned(); + return Ok(relative_from_resolved(base, target)); + } + + // Slow path: avoid current_dir() for already-absolute paths. Windows + // drive-relative receivers still take try_absolutize here. let base = if base_path.is_absolute() { normalize_for_resolution(base_path).into_owned() } else { @@ -852,6 +945,13 @@ where assert!(cwd.as_ref().is_absolute(), "explicit current directory must be absolute"); + // Same pure-lexical gate as ambient relative. try_relative_both_relative_via_cwd + // already requires classification; do not treat every !has_root path as pure + // relative (Windows drive-relative keeps absolutize_with / drive rules below). + if let Some(relative) = try_relative_both_relative_via_cwd(target_path, base_path, cwd.as_ref()) { + return RelativeOutcome::Native(relative); + } + let base = if base_path.is_absolute() { normalize_for_resolution(base_path).into_owned() } else { diff --git a/tests/relative_lexical.rs b/tests/relative_lexical.rs index d0436a9..f0de3a4 100644 --- a/tests/relative_lexical.rs +++ b/tests/relative_lexical.rs @@ -148,6 +148,32 @@ fn deep_unequal_parent_miss_uses_the_cwd_dependent_fallback() { assert_eq!(target.relative(base).as_os_str(), expected.as_os_str()); } +/// Ambient `relative` for Windows drive-relative inputs must use `try_absolutize` +/// (per-drive `std::path::absolute`), not the pure-lexical shared-cwd stack path. +/// Drive-relative paths are `!has_root()` but carry a Prefix — treating every +/// `!has_root` pair as pure relative regressed the different-drive allocation row. +#[cfg(windows)] +#[test] +fn ambient_drive_relative_relative_matches_try_absolutize_composition() { + for (target, base) in [ + (r"C:dist\assets\index.js", r"C:dist\chunks"), + (r"D:dist\assets\index.js", r"C:dist\chunks"), + (r"C:dist\assets\index.js", r"D:dist\chunks"), + ] { + let target_path = Path::new(target); + let base_path = Path::new(base); + let actual = target_path.relative(base_path); + let resolved_base = base_path.try_absolutize().expect("absolutize base").into_owned(); + let resolved_target = target_path.try_absolutize().expect("absolutize target").into_owned(); + let expected = resolved_target.relative(resolved_base.as_path()); + assert_eq!( + actual.as_os_str(), + expected.as_os_str(), + "target {target:?}, base {base:?}: ambient relative must match try_absolutize composition", + ); + } +} + #[cfg(windows)] #[test] fn windows_explicit_cwd_preserves_or_cancels_drive_relative_context() {