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
10 changes: 10 additions & 0 deletions .agents/docs/performance-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ The representative corpus therefore starts from Rolldown's public tracked-path d

The default workload for performance design and acceptance is paths that `Path::to_str()` can represent losslessly as valid UTF-8. This is a performance-evaluation assumption, not a precondition of the public API. An optimization is valid and valuable when it measurably improves that workload even if native non-UTF-8 inputs become slower, provided those inputs still preserve their exact bytes or wide units and all public results and errors remain correct, panic-free, and memory-safe. Non-UTF-8 latency is not a default merge gate: keep correctness coverage and retain a focused benchmark when a known tradeoff needs to stay visible, but do not add complexity or sacrifice a demonstrated UTF-8 gain solely to equalize a non-UTF-8 microbenchmark without consumer evidence.

## Windows, macOS, and Linux are the performance targets

[VOUCHED @hyf0 2026-07-14]

Performance design, measurement, optimization, and regression acceptance target Windows, macOS, and Linux. Architecture-specific changes within those operating systems still require direct evidence for the architecture whose implementation changes; results from one operating system or architecture must not be generalized to another. Other platforms remain supported for correctness, compilation, public semantics, encoding preservation, panic freedom, and memory safety, but their latency, throughput, allocation behavior, and generated-code size are not performance goals or default merge gates. Do not extend performance-specific branches to additional operating systems without concrete consumer demand and target-platform evidence.

## Pre-scan before allocation

Prefer a cheap classification scan when it can prove that an operation may borrow its input, reuse an owned buffer, or skip building an intermediate result. Rolldown's tracked repository paths are short enough for this to be plausible: the recorded relative-path distribution is 75 encoded bytes at p50, 110 at p95, and 177 at the maximum. A scan over that range usually stays in cache, while crossing the allocator boundary has fixed bookkeeping and ownership costs.
Expand Down Expand Up @@ -52,6 +58,10 @@ The current suite treats `relative` itself as the borrowing API. The relative-re

The committed Linux and Windows snapshots record the current clean targets: a canonical descendant `relative -> Cow<Path>` uses zero allocation calls; `Cow::into_owned` uses one; the descendant and upward ordinary strict final-`String` compositions each use one; clean `PathBuf::into_normalized` and valid-Unicode `PathBuf::into_slash` use zero; and `absolutize_with` with a clean relative receiver and an owned cwd uses no fresh allocation and one buffer-growth reallocation. Requested byte totals remain target-specific. Local prints on other hosts may be used for investigation but are not continuous gates.

On macOS AArch64, the existing NEON common-prefix scan makes it profitable to determine the shared component prefix before normalization classification for absolute `relative` calls. An upward result is necessarily owned; when both unmatched suffixes are clean, identical dirty components inside the shared prefix cancel on both sides and do not need a second scan. Descendants still classify both full inputs before borrowing. Exhaustive short-path comparison against unconditional full normalization covers both result text and the `Cow` variant, with separate multibyte UTF-8 prefix and mismatch cases. The optimized dispatch is limited to macOS AArch64 with NEON. macOS x86_64, Windows, and Linux retain the previous implementation until the affected target has its own evidence; other operating systems retain the previous implementation and are not performance targets.

Runtime evidence compares freshly built binaries in isolated target directories against accepted baseline commit `886fc357f228e8a89e49103fdaa88db7eab70877`. On one Apple M3 Pro run using only the existing `relative` benchmark, 150 samples, a two-second warm-up, and a five-second measurement, `short_common_prefix` improved by 9.41% for the natural result and 9.37% after ownership, `deep_siblings` by 68.31% and 68.58%, and `different_subtrees` by 27.83% and 29.84%. The ordinary descendant control was unchanged for the natural result and stayed within the 1% noise threshold after ownership; the same-directory control stayed within noise for the natural result and improved by 3.17% after ownership. Two full `dot_slow_path` comparisons moved in opposite directions, including one 2.73% owned-result regression followed by a 1.94% improvement. A balanced `main/candidate/candidate/main/main/candidate/candidate/main` sequence of shorter 50-sample runs did not reproduce the regression: the candidate averages were 1.9% lower for the natural result and 2.3% lower after ownership, so this row supports only a no-demonstrated-regression conclusion rather than a gain claim. The AArch64 release IR grows from 16,665 lines and 430 copies to 16,777 and 431, so the change is justified by runtime rather than code size; macOS x86_64 produces the same 15,469 lines and 385 copies as the baseline.

Two same-machine Apple M3 Pro runs with `cached_current_dir` compared the final Rolldown benchmark against locally saved `before-api-redesign-rolldown` data from the development branch. The package-sideEffects descendant main-Cow slope moved from 79.49 ns to 70.12 ns and 71.69 ns, while its upward control moved from 110.89 ns to 97.51 ns and 102.82 ns. A caller-side `strip_prefix` pre-check did not produce a stable hit advantage: its descendant slope was 68.94 ns in one run and 74.26 ns in the repeat, on opposite sides of the main-Cow result. Its upward miss was consistently 174.20–175.82 ns, about 70–80% slower than the main-Cow miss. These local data predate the accepted main baseline and are not results for commit `9e6b627`. The generic API therefore keeps one shared scan; a specialized Rolldown pre-check needs a controlled weighted or instruction-count result before adoption even though that caller's recorded hit rate is 4,888 of 4,890.

The final descendant strict-`String` variants were effectively equal in the full run: 80.09 ns through borrowed `to_slash().into_owned()` and 80.24 ns through the ordinary `into_owned().into_slash()` composition. The latter is the migration target because it also reuses an already-owned upward result and meets the one-allocation descendant and upward snapshots. Clean join plus consuming normalization was unchanged at 76.34 ns versus 76.35 ns in that locally saved development checkpoint, and its final-`String` variant was 79.96 ns versus 79.60 ns. Dirty consuming normalization improved from 170.64 ns to 155.09 ns, while its final-`String` result was effectively unchanged at 161.60 ns versus 162.26 ns. These are local point estimates, not cross-platform claims or measurements against the accepted main baseline.
Expand Down
152 changes: 152 additions & 0 deletions src/impl_sugar_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,67 @@ fn windows_prefixes_eq_ignore_ascii_case(

/// String-based relative path computation. Dispatches to the fast path when
/// the component spelling is already canonical, otherwise normalizes first.
#[cfg(all(target_os = "macos", target_arch = "aarch64", target_feature = "neon"))]
fn relative_str<'a>(target: &'a str, base: &str) -> Cow<'a, str> {
let target = target.trim_end_matches('/');
let base = base.trim_end_matches('/');
relative_str_suffix_validated(target, base)
}

#[cfg(all(
any(target_os = "macos", target_os = "linux"),
any(test, all(target_os = "macos", target_arch = "aarch64", target_feature = "neon"))
))]
fn relative_str_suffix_validated<'a>(target: &'a str, base: &str) -> Cow<'a, str> {
let common_byte_len = common_prefix_len_case_sensitive(target.as_bytes(), base.as_bytes());
let at_boundary = (common_byte_len == target.len() && common_byte_len == base.len())
|| (common_byte_len == target.len() && base.as_bytes().get(common_byte_len) == Some(&b'/'))
|| (common_byte_len == base.len() && target.as_bytes().get(common_byte_len) == Some(&b'/'));
let common_prefix = if at_boundary {
common_byte_len
} else {
memrchr(b'/', &target.as_bytes()[..common_byte_len]).unwrap_or(0)
};

if at_boundary && common_byte_len == base.len() {
if needs_relative_normalization(target) || needs_relative_normalization(base) {
return Cow::Owned(relative_str_slow(target, base));
}
return Cow::Borrowed(target[common_prefix..].trim_start_matches('/'));
}

let base_remaining = &base.as_bytes()[common_prefix..];
let mut ups = 0u32;
let mut offset = 0;
while offset < base_remaining.len() {
if base_remaining[offset] == b'/' {
offset += 1;
continue;
}
ups += 1;
offset = match memchr(b'/', &base_remaining[offset..]) {
Some(pos) => offset + pos + 1,
None => base_remaining.len(),
};
}

// Upward results are always owned. Shared dirty components normalize to the
// same prefix on both sides, so only the unmatched suffixes can change the
// result. Descendants still scan both full inputs before borrowing a suffix.
let needs_normalization = if ups == 0 {
needs_relative_normalization(target) || needs_relative_normalization(base)
} else {
needs_relative_normalization(&target[common_prefix..])
|| needs_relative_normalization(&base[common_prefix..])
};
if needs_normalization {
Cow::Owned(relative_str_slow(target, base))
} else {
relative_str_from_parts(target, common_prefix, ups as usize)
}
}

#[cfg(not(all(target_os = "macos", target_arch = "aarch64", target_feature = "neon")))]
fn relative_str<'a>(target: &'a str, base: &str) -> Cow<'a, str> {
let target = target.trim_end_matches('/');
let base = base.trim_end_matches('/');
Expand Down Expand Up @@ -1678,6 +1739,7 @@ fn needs_relative_normalization(path: &str) -> bool {

/// Fast path: no normalization needed. Operates directly on `&str` slices
/// with zero intermediate allocation.
#[cfg(not(all(target_os = "macos", target_arch = "aarch64", target_feature = "neon")))]
fn relative_str_fast<'a>(target: &'a str, base: &str) -> Cow<'a, str> {
let common_byte_len = {
#[cfg(target_family = "windows")]
Expand Down Expand Up @@ -1742,6 +1804,26 @@ fn relative_str_fast<'a>(target: &'a str, base: &str) -> Cow<'a, str> {
Cow::Owned(result)
}

#[cfg(all(
any(target_os = "macos", target_os = "linux"),
any(test, all(target_os = "macos", target_arch = "aarch64", target_feature = "neon"))
))]
fn relative_str_from_parts<'a>(target: &'a str, common_prefix: usize, ups: usize) -> Cow<'a, str> {
let target_suffix = target[common_prefix..].trim_start_matches('/');
if ups == 0 {
return Cow::Borrowed(target_suffix);
}
let suffix_iter = if target_suffix.is_empty() { None } else { Some(target_suffix) };
let mut result = String::with_capacity(ups * 3 + target_suffix.len());
std::iter::repeat_n("..", ups).chain(suffix_iter).for_each(|s| {
if !result.is_empty() {
result.push('/');
}
result.push_str(s);
});
Cow::Owned(result)
}

/// Slow path: normalize `.` and `..` components first, then compute relative path.
fn relative_str_slow(target: &str, base: &str) -> String {
let target_parts = normalize_parts(target);
Expand Down Expand Up @@ -1792,6 +1874,76 @@ fn normalize_parts(path: &str) -> StrVec<'_> {
parts
}

#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
mod relative_str_tests {
use std::borrow::Cow;

use super::{needs_relative_normalization, relative_str_slow, relative_str_suffix_validated};

fn assert_suffix_validation_matches_full_normalization(target: &str, base: &str) {
let target = target.trim_end_matches('/');
let base = base.trim_end_matches('/');
let actual = relative_str_suffix_validated(target, base);
let expected = relative_str_slow(target, base);
assert_eq!(actual, expected, "target {target:?}, base {base:?}");

let dirty = needs_relative_normalization(target) || needs_relative_normalization(base);
let base_is_component_prefix =
target.strip_prefix(base).is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/'));
let should_borrow = !dirty && base_is_component_prefix;
assert_eq!(
matches!(actual, Cow::Borrowed(_)),
should_borrow,
"target {target:?}, base {base:?} returned the wrong Cow variant",
);
}

fn short_absolute_spellings() -> Vec<String> {
let components = ["", ".", "..", "a", "A", "b"];
let mut paths = Vec::new();
for depth in 0..=3u32 {
for mut index in 0..components.len().pow(depth) {
let mut path = String::from("/");
for component_index in 0..depth {
if component_index > 0 {
path.push('/');
}
path.push_str(components[index % components.len()]);
index /= components.len();
}
paths.push(path.clone());
if path.len() > 1 {
path.push('/');
paths.push(path);
}
}
}
paths.sort_unstable();
paths.dedup();
paths
}

#[test]
fn suffix_only_validation_matches_full_normalization_for_short_paths() {
let paths = short_absolute_spellings();
for target in &paths {
for base in &paths {
assert_suffix_validation_matches_full_normalization(target, base);
}
}
}

#[test]
fn suffix_only_validation_handles_multibyte_prefixes_and_mismatches() {
let paths = ["/é", "/ê", "/é/a", "/ê/a", "/猫", "/猫/src", "/猫/../src/a", "/猫/../src/b"];
for target in paths {
for base in paths {
assert_suffix_validation_matches_full_normalization(target, base);
}
}
}
}

/// Replace `\` with `/` using memchr SIMD search. Returns the input unchanged
/// (zero allocation) when no backslashes are present.
#[cfg(target_family = "windows")]
Expand Down
Loading