Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions .agents/docs/performance-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions benchmarks/allocations/x86_64-pc-windows-msvc.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/allocations/x86_64-unknown-linux-gnu.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
140 changes: 120 additions & 20 deletions src/impl_sugar_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,32 +118,77 @@ fn try_relative_lexically(target: &Path, base: &Path) -> Option<PathBuf> {

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<OsStrVec<'a>> {
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<PathBuf> {
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();

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::<usize>()
Expand All @@ -160,17 +205,42 @@ fn try_relative_lexically(target: &Path, base: &Path) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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")]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions tests/relative_lexical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading