From 500497accb7b1433851978229ead5d1ed9f29025 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 13:55:27 +0800 Subject: [PATCH 1/5] chore: extend path performance baselines --- .agents/docs/performance-strategy.md | 2 +- Cargo.toml | 4 ++ benches/hot_path_baselines.rs | 80 ++++++++++++++++++++++++++++ benchmarks/README.md | 2 + 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 benches/hot_path_baselines.rs diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index dffa70a..b3f6f21 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -61,7 +61,7 @@ The pre-baseline implementation had costs that the recorded suite needed to pres ## Baseline protocol -Pin the Rust toolchain and Cargo lock, keep workload names stable, regenerate target-specific allocation snapshots, and run formatting, Clippy, default/all-feature tests, benchmark smoke tests, and link checks. The accepted baseline must land on main before implementation behavior changes, and the optimization branch must be compared from that main commit rather than from an earlier private checkpoint. CodSpeed data lives outside the repository and becomes useful only after that commit is uploaded. A checked-in snapshot provides evidence only for the API, target, and execution environment that actually produced it; the historical Windows-GNU files do not substitute for a native Windows CI run of the final API. +Pin the Rust toolchain and Cargo lock, keep workload names stable, regenerate target-specific allocation snapshots, and run formatting, Clippy, default/all-feature tests, benchmark smoke tests, and link checks. A new timed benchmark case and the implementation it measures must never enter through one PR. Open a behavior-neutral benchmark-baseline PR first and put a new baseline family in its own benchmark binary so fat LTO cannot change the layout of existing binaries. Then base the implementation PR on that PR's branch so CodSpeed executes the new case on both sides and reports the implementation delta; after the baseline merges, rebase or retarget the implementation PR onto the merged baseline without collapsing the two review steps. CodSpeed data lives outside the repository and becomes useful only after the benchmark commit is uploaded. A checked-in snapshot provides evidence only for the API, target, and execution environment that actually produced it; the historical Windows-GNU files do not substitute for a native Windows CI run of the final API. The timed benchmark allocator is mimalloc 0.1.64 to match Rolldown. Do not enable `target-cpu=native` in shared baselines: it breaks cross-run comparability and can silently select a different SIMD path. If explicit SIMD is evaluated later, give each fixed target-feature configuration its own benchmark identity. diff --git a/Cargo.toml b/Cargo.toml index a26ffe2..26da514 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,10 @@ harness = false name = "relative" harness = false +[[bench]] +name = "hot_path_baselines" +harness = false + [[bench]] name = "to_slash" harness = false diff --git a/benches/hot_path_baselines.rs b/benches/hot_path_baselines.rs new file mode 100644 index 0000000..17fc9e1 --- /dev/null +++ b/benches/hot_path_baselines.rs @@ -0,0 +1,80 @@ +use std::hint::black_box; +use std::path::Path; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use sugar_path::SugarPath; + +mod support; + +#[cfg(all(target_family = "unix", not(target_os = "cygwin")))] +use support::workloads::RELATIVE_CASES; +#[cfg(any(unix, windows))] +use support::workloads::{ROLLDOWN_ROOT, invalid_unicode_path}; + +fn bench_hot_path_baselines(criterion: &mut Criterion) { + #[cfg(not(target_family = "windows"))] + let (dirty_early, dirty_late) = ( + "/./workspace/rolldown/crates/rolldown/src/module_loader/module_task.rs", + "/workspace/rolldown/crates/rolldown/src/module_loader/./module_task.rs", + ); + #[cfg(target_family = "windows")] + let (dirty_early, dirty_late) = ( + r"C:\.\workspace\rolldown\crates\rolldown\src\module_loader\module_task.rs", + r"C:\workspace\rolldown\crates\rolldown\src\module_loader\.\module_task.rs", + ); + let mut group = criterion.benchmark_group("normalize/classifier_position/valid"); + for (name, path) in [("dirty_early", dirty_early), ("dirty_late", dirty_late)] { + group.throughput(Throughput::Bytes(path.len() as u64)); + group.bench_function(name, |bencher| { + bencher.iter(|| black_box(Path::new(black_box(path)).normalize())); + }); + } + group.finish(); + + #[cfg(any(unix, windows))] + { + let invalid = invalid_unicode_path(); + let invalid_name = invalid.file_name().expect("invalid fixture has a file name").to_owned(); + let mut dirty_before_invalid = invalid.clone(); + dirty_before_invalid.pop(); + dirty_before_invalid.push("."); + dirty_before_invalid.push(&invalid_name); + let mut invalid_before_dirty_late = invalid.clone(); + invalid_before_dirty_late.push("late"); + invalid_before_dirty_late.push("."); + invalid_before_dirty_late.push("file.js"); + let mut group = criterion.benchmark_group("normalize/invalid_encoding"); + group.throughput(Throughput::Bytes(dirty_before_invalid.as_os_str().len() as u64)); + group.bench_function("dirty_before_invalid", |bencher| { + bencher.iter(|| black_box(black_box(dirty_before_invalid.as_path()).normalize())); + }); + group.throughput(Throughput::Bytes(invalid_before_dirty_late.as_os_str().len() as u64)); + group.bench_function("invalid_before_dirty_late", |bencher| { + bencher.iter(|| black_box(black_box(invalid_before_dirty_late.as_path()).normalize())); + }); + group.finish(); + + let base = Path::new(ROLLDOWN_ROOT); + let mut group = criterion.benchmark_group("relative/slow_path"); + group + .throughput(Throughput::Bytes((invalid.as_os_str().len() + base.as_os_str().len()) as u64)); + group.bench_function("invalid_encoding_absolute", |bencher| { + bencher.iter(|| black_box(invalid.as_path()).relative(black_box(base))); + }); + group.finish(); + } + + #[cfg(all(target_family = "unix", not(target_os = "cygwin")))] + { + let case = &RELATIVE_CASES[0]; + let mut group = criterion.benchmark_group("relative/string_receiver/natural_result"); + group.throughput(Throughput::Bytes((case.target.len() + case.base.len()) as u64)); + group.bench_function(case.name, |bencher| { + bencher.iter(|| black_box(black_box(case.target).relative(black_box(case.base)))); + }); + group.finish(); + } +} + +criterion_group!(benches, bench_hot_path_baselines); +criterion_main!(benches); diff --git a/benchmarks/README.md b/benchmarks/README.md index 187d77d..87112f7 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -10,6 +10,8 @@ Every timed benchmark black-boxes both input and output. Setup that is not part Benchmark and allocation scenario names describe inputs and requested output shapes. Keep accepted identities unchanged when an implementation starts borrowing, consumes an owned buffer, or removes an intermediate value. Implementation-specific alternatives may use separate control names only when they measure additional work; do not duplicate an existing timed operation under a mechanism-specific name. Stable public-operation rows let CodSpeed and saved Criterion baselines compare the accepted baseline with later implementations. +Never add a timed benchmark case in the same PR as the implementation it is intended to measure. First open a behavior-neutral baseline PR containing only the benchmark definition and make sure it produces a valid baseline result. Because the bench profile uses fat LTO, add a new baseline family in a dedicated benchmark binary so its code cannot perturb the layout of existing benchmark binaries. Then open the implementation PR with its base branch set to the baseline PR's branch, so CodSpeed executes the case on both sides and reports the implementation delta instead of labeling the case as new. After the baseline PR merges, rebase or retarget the implementation PR onto the merged commit without combining the two review steps. + In paired timing rows, `borrowed_receiver` means the operation receives a borrowed path view, while `owned_receiver` reserves the same final-output contract for an operation that may consume a prepared `PathBuf`. `natural_result` means the method's direct public return value; `pathbuf_result`, `string_result`, and the ArcStr slash labels name the requested intermediate or final container explicitly. The v2 baseline duplicates a borrowed implementation where it had no consuming method, allowing the final consuming implementation to retain the ID while changing only the ownership mechanism. Rows whose public path spelling or root semantics intentionally change in the breaking API are contract coverage, not same-output speed comparisons. In particular, do not use the trailing-separator or dot-separator normalization rows, or the Windows verbatim-UNC different-share relative row, to claim an algorithmic speedup against the baseline: the final API deliberately returns a different value in those cases. Keep them in the suite so the cost of the selected contract remains visible, and base same-output performance claims on rows whose exact result is unchanged. From a6aded6dba3dc538c69b044de40aa91c62d619fc Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 14:37:32 +0800 Subject: [PATCH 2/5] docs: define UTF-8 performance priority --- .agents/docs/performance-strategy.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index b3f6f21..1c20d24 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -14,6 +14,8 @@ The scan is not free. Measure clean hits, dirty inputs that fail early, dirty in The pinned Rolldown tree proves only a representation bound, not a runtime hit rate. All 12,287 tracked paths are lexically clean and valid UTF-8; they borrow under Unix spelling and after conversion to native Windows separators. Passing the repository-relative Git `/` spelling unchanged on Windows would borrow only 35 root-level paths, or 0.2849%. Actual resolver output is normally native, while bindings, plugins, and emitted filenames can still supply `/`, so record Linux and Windows separately. +Treat paths that can be represented losslessly as valid UTF-8 as the default performance target unless a measured consumer workload establishes a different distribution. Native non-UTF-8 paths remain a correctness requirement: preserve their exact bytes or wide units, public ownership and error semantics, and panic-free behavior; do not turn linear work into quadratic work or create another slowdown that grows disproportionately with input length. A measured, limited slowdown for those inputs may be accepted for a demonstrated UTF-8 workload gain, but keep non-UTF-8 correctness tests and focused benchmark rows so the cost remains visible. This priority is never permission to unwrap `Path::to_str`, perform a lossy conversion, or introduce unchecked Unicode assumptions. + The 20 pinned Rolldown `normalize` call sites also show why the next owner matters: eight immediately call `into_owned`, so a borrowed classifier result still clones once. Before commit `22af24b`, canonical leading-parent output from `relative` was already normalized but failed the classifier at byte zero and added one allocation. That no-op rebuild is now removed; consuming and in-place experiments remain relevant for callers that immediately force ownership. Treat late misses such as `base.join("./specifier")` as the scan-overhead counterweight. If a real Rolldown run is instrumented, keep aggregate-only counters outside timed runs: `borrowed_clean`, `dirty_early`, `dirty_late`, `invalid_encoding`, total input bytes, and bytes examined before the decision. Separate hot module-ID normalization, the relative pipeline, and other call sites; never record raw paths, hashes, or reversible samples. From 592d250e8ba824675ac230bf25a1005b94555649 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 14:40:03 +0800 Subject: [PATCH 3/5] docs: vouch UTF-8 performance target --- .agents/docs/README.md | 1 + .agents/docs/performance-strategy.md | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 23c4005..2c0d53f 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -23,6 +23,7 @@ Use this map to load only the context that bears on the work in front of you. Al - Dependency or fast-path changes → [Performance dependencies](./technology-stack.md#performance-dependencies). - Rust edition, toolchain policy, or the CI operating-system matrix → [Pinned stable Rust and standard path semantics](./technology-stack.md#pinned-stable-rust-and-standard-path-semantics) and [Platform behavior stays behind compile-time branches](./architecture.md#platform-behavior-stays-behind-compile-time-branches). - Benchmarks, `Cargo.toml` benchmark targets, or CodSpeed → [Benchmarks guard user-facing properties](./technology-stack.md#benchmarks-guard-user-facing-properties). +- Performance optimization scope or a non-UTF-8 performance tradeoff → [UTF-8 is the default performance target](./performance-strategy.md#utf-8-is-the-default-performance-target). - Historical Windows-GNU Docker/Wine reproduction → [Local Docker validation is opt-in](./conventions.md#local-docker-validation-is-opt-in) and [Windows GNU local execution gate](../../benchmarks/windows-gnu.md#local-execution-gate). - Performance strategy, baseline changes, Rolldown workloads, allocation work, or API performance → [Performance strategy](./performance-strategy.md). - Test fixtures or assertions → [Testing conventions](./conventions.md). diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index 1c20d24..5e3eccb 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -6,6 +6,12 @@ Rolldown is SugarPath's primary consumer. At Rolldown commit [`b9823050b`](https The representative corpus therefore starts from Rolldown's public tracked-path distribution and includes its composed operations. Branch-specific microbenchmarks remain separate so a weighted aggregate cannot hide a slow path. Rolldown traces record only aggregate path shape and branch data, never private raw paths or reversible hashes. +## UTF-8 is the default performance target + +[VOUCHED @hyf0 2026-07-13] + +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. + ## 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. @@ -14,8 +20,6 @@ The scan is not free. Measure clean hits, dirty inputs that fail early, dirty in The pinned Rolldown tree proves only a representation bound, not a runtime hit rate. All 12,287 tracked paths are lexically clean and valid UTF-8; they borrow under Unix spelling and after conversion to native Windows separators. Passing the repository-relative Git `/` spelling unchanged on Windows would borrow only 35 root-level paths, or 0.2849%. Actual resolver output is normally native, while bindings, plugins, and emitted filenames can still supply `/`, so record Linux and Windows separately. -Treat paths that can be represented losslessly as valid UTF-8 as the default performance target unless a measured consumer workload establishes a different distribution. Native non-UTF-8 paths remain a correctness requirement: preserve their exact bytes or wide units, public ownership and error semantics, and panic-free behavior; do not turn linear work into quadratic work or create another slowdown that grows disproportionately with input length. A measured, limited slowdown for those inputs may be accepted for a demonstrated UTF-8 workload gain, but keep non-UTF-8 correctness tests and focused benchmark rows so the cost remains visible. This priority is never permission to unwrap `Path::to_str`, perform a lossy conversion, or introduce unchecked Unicode assumptions. - The 20 pinned Rolldown `normalize` call sites also show why the next owner matters: eight immediately call `into_owned`, so a borrowed classifier result still clones once. Before commit `22af24b`, canonical leading-parent output from `relative` was already normalized but failed the classifier at byte zero and added one allocation. That no-op rebuild is now removed; consuming and in-place experiments remain relevant for callers that immediately force ownership. Treat late misses such as `base.join("./specifier")` as the scan-overhead counterweight. If a real Rolldown run is instrumented, keep aggregate-only counters outside timed runs: `borrowed_clean`, `dirty_early`, `dirty_late`, `invalid_encoding`, total input bytes, and bytes examined before the decision. Separate hot module-ID normalization, the relative pipeline, and other call sites; never record raw paths, hashes, or reversible samples. From 4ff7624f2a28fbcc543db0996389813eba967887 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 14:47:42 +0800 Subject: [PATCH 4/5] bench: label non-UTF-8 workloads explicitly --- benches/hot_path_baselines.rs | 42 +++++++++++++++++------------------ benches/normalize.rs | 12 +++++----- benches/support/workloads.rs | 4 ++-- benches/to_slash.rs | 16 ++++++------- benchmarks/README.md | 2 ++ 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/benches/hot_path_baselines.rs b/benches/hot_path_baselines.rs index 17fc9e1..7acdf6f 100644 --- a/benches/hot_path_baselines.rs +++ b/benches/hot_path_baselines.rs @@ -9,7 +9,7 @@ mod support; #[cfg(all(target_family = "unix", not(target_os = "cygwin")))] use support::workloads::RELATIVE_CASES; #[cfg(any(unix, windows))] -use support::workloads::{ROLLDOWN_ROOT, invalid_unicode_path}; +use support::workloads::{ROLLDOWN_ROOT, non_utf8_path}; fn bench_hot_path_baselines(criterion: &mut Criterion) { #[cfg(not(target_family = "windows"))] @@ -33,33 +33,33 @@ fn bench_hot_path_baselines(criterion: &mut Criterion) { #[cfg(any(unix, windows))] { - let invalid = invalid_unicode_path(); - let invalid_name = invalid.file_name().expect("invalid fixture has a file name").to_owned(); - let mut dirty_before_invalid = invalid.clone(); - dirty_before_invalid.pop(); - dirty_before_invalid.push("."); - dirty_before_invalid.push(&invalid_name); - let mut invalid_before_dirty_late = invalid.clone(); - invalid_before_dirty_late.push("late"); - invalid_before_dirty_late.push("."); - invalid_before_dirty_late.push("file.js"); - let mut group = criterion.benchmark_group("normalize/invalid_encoding"); - group.throughput(Throughput::Bytes(dirty_before_invalid.as_os_str().len() as u64)); - group.bench_function("dirty_before_invalid", |bencher| { - bencher.iter(|| black_box(black_box(dirty_before_invalid.as_path()).normalize())); + let non_utf8 = non_utf8_path(); + let non_utf8_name = non_utf8.file_name().expect("non-UTF-8 fixture has a file name").to_owned(); + let mut dirty_before_non_utf8 = non_utf8.clone(); + dirty_before_non_utf8.pop(); + dirty_before_non_utf8.push("."); + dirty_before_non_utf8.push(&non_utf8_name); + let mut non_utf8_before_dirty_late = non_utf8.clone(); + non_utf8_before_dirty_late.push("late"); + non_utf8_before_dirty_late.push("."); + non_utf8_before_dirty_late.push("file.js"); + let mut group = criterion.benchmark_group("normalize/non_utf8"); + group.throughput(Throughput::Bytes(dirty_before_non_utf8.as_os_str().len() as u64)); + group.bench_function("dirty_before_non_utf8", |bencher| { + bencher.iter(|| black_box(black_box(dirty_before_non_utf8.as_path()).normalize())); }); - group.throughput(Throughput::Bytes(invalid_before_dirty_late.as_os_str().len() as u64)); - group.bench_function("invalid_before_dirty_late", |bencher| { - bencher.iter(|| black_box(black_box(invalid_before_dirty_late.as_path()).normalize())); + group.throughput(Throughput::Bytes(non_utf8_before_dirty_late.as_os_str().len() as u64)); + group.bench_function("non_utf8_before_dirty_late", |bencher| { + bencher.iter(|| black_box(black_box(non_utf8_before_dirty_late.as_path()).normalize())); }); group.finish(); let base = Path::new(ROLLDOWN_ROOT); let mut group = criterion.benchmark_group("relative/slow_path"); group - .throughput(Throughput::Bytes((invalid.as_os_str().len() + base.as_os_str().len()) as u64)); - group.bench_function("invalid_encoding_absolute", |bencher| { - bencher.iter(|| black_box(invalid.as_path()).relative(black_box(base))); + .throughput(Throughput::Bytes((non_utf8.as_os_str().len() + base.as_os_str().len()) as u64)); + group.bench_function("non_utf8_absolute_target", |bencher| { + bencher.iter(|| black_box(non_utf8.as_path()).relative(black_box(base))); }); group.finish(); } diff --git a/benches/normalize.rs b/benches/normalize.rs index c116bc2..5140b99 100644 --- a/benches/normalize.rs +++ b/benches/normalize.rs @@ -7,7 +7,7 @@ use sugar_path::{SugarPath, SugarPathBuf}; mod support; #[cfg(any(unix, windows))] -use support::workloads::invalid_unicode_path; +use support::workloads::non_utf8_path; use support::workloads::{ CANONICAL_LEADING_PARENTS, CURRENT_DIRECTORY_CASES, DIRTY_PATHS, LEADING_PARENT_SCAN_CASES, PathCase, ROLLDOWN_PATHS, @@ -49,11 +49,11 @@ fn bench_normalize(criterion: &mut Criterion) { #[cfg(any(unix, windows))] { - let invalid = invalid_unicode_path(); - let mut group = criterion.benchmark_group("normalize/invalid_encoding"); - group.throughput(Throughput::Bytes(invalid.as_os_str().len() as u64)); - group.bench_function("lexically_clean", |bencher| { - bencher.iter(|| black_box(black_box(invalid.as_path()).normalize())); + let non_utf8 = non_utf8_path(); + let mut group = criterion.benchmark_group("normalize/non_utf8"); + group.throughput(Throughput::Bytes(non_utf8.as_os_str().len() as u64)); + group.bench_function("non_utf8_lexically_clean", |bencher| { + bencher.iter(|| black_box(black_box(non_utf8.as_path()).normalize())); }); group.finish(); } diff --git a/benches/support/workloads.rs b/benches/support/workloads.rs index 5d94801..85cf8b3 100644 --- a/benches/support/workloads.rs +++ b/benches/support/workloads.rs @@ -19,7 +19,7 @@ pub struct RelativeCase { } #[cfg(unix)] -pub fn invalid_unicode_path() -> PathBuf { +pub fn non_utf8_path() -> PathBuf { use std::os::unix::ffi::OsStringExt; PathBuf::from(OsString::from_vec( @@ -28,7 +28,7 @@ pub fn invalid_unicode_path() -> PathBuf { } #[cfg(windows)] -pub fn invalid_unicode_path() -> PathBuf { +pub fn non_utf8_path() -> PathBuf { use std::os::windows::ffi::OsStringExt; let mut units: Vec = diff --git a/benches/to_slash.rs b/benches/to_slash.rs index a147710..923cfdc 100644 --- a/benches/to_slash.rs +++ b/benches/to_slash.rs @@ -10,7 +10,7 @@ use support::workloads::ROLLDOWN_PATHS; #[cfg(target_family = "windows")] use support::workloads::WINDOWS_SLASH_CASES; #[cfg(any(unix, windows))] -use support::workloads::invalid_unicode_path; +use support::workloads::non_utf8_path; fn bench_to_slash(criterion: &mut Criterion) { let mut group = @@ -89,14 +89,14 @@ fn bench_to_slash(criterion: &mut Criterion) { #[cfg(any(unix, windows))] { - let invalid = invalid_unicode_path(); - let mut group = criterion.benchmark_group("slash/invalid_unicode"); - group.throughput(Throughput::Bytes(invalid.as_os_str().len() as u64)); - group.bench_function("borrowed_receiver/fallible_result", |bencher| { - bencher.iter(|| black_box(black_box(invalid.as_path()).try_to_slash())); + let non_utf8 = non_utf8_path(); + let mut group = criterion.benchmark_group("slash/non_utf8"); + group.throughput(Throughput::Bytes(non_utf8.as_os_str().len() as u64)); + group.bench_function("borrowed_receiver/non_utf8_fallible_result", |bencher| { + bencher.iter(|| black_box(black_box(non_utf8.as_path()).try_to_slash())); }); - group.bench_function("borrowed_receiver/lossy_result", |bencher| { - bencher.iter(|| black_box(black_box(invalid.as_path()).to_slash_lossy())); + group.bench_function("borrowed_receiver/non_utf8_lossy_result", |bencher| { + bencher.iter(|| black_box(black_box(non_utf8.as_path()).to_slash_lossy())); }); group.finish(); } diff --git a/benchmarks/README.md b/benchmarks/README.md index 87112f7..7bed2c6 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -10,6 +10,8 @@ Every timed benchmark black-boxes both input and output. Setup that is not part Benchmark and allocation scenario names describe inputs and requested output shapes. Keep accepted identities unchanged when an implementation starts borrowing, consumes an owned buffer, or removes an intermediate value. Implementation-specific alternatives may use separate control names only when they measure additional work; do not duplicate an existing timed operation under a mechanism-specific name. Stable public-operation rows let CodSpeed and saved Criterion baselines compare the accepted baseline with later implementations. +If a timed path input cannot be represented losslessly as UTF-8, include `non_utf8` in the leaf benchmark name rather than only in a parent group. GitHub and CodSpeed summaries may show only the leaf name, so each such row must remain recognizable without its full benchmark path. + Never add a timed benchmark case in the same PR as the implementation it is intended to measure. First open a behavior-neutral baseline PR containing only the benchmark definition and make sure it produces a valid baseline result. Because the bench profile uses fat LTO, add a new baseline family in a dedicated benchmark binary so its code cannot perturb the layout of existing benchmark binaries. Then open the implementation PR with its base branch set to the baseline PR's branch, so CodSpeed executes the case on both sides and reports the implementation delta instead of labeling the case as new. After the baseline PR merges, rebase or retarget the implementation PR onto the merged commit without combining the two review steps. In paired timing rows, `borrowed_receiver` means the operation receives a borrowed path view, while `owned_receiver` reserves the same final-output contract for an operation that may consume a prepared `PathBuf`. `natural_result` means the method's direct public return value; `pathbuf_result`, `string_result`, and the ArcStr slash labels name the requested intermediate or final container explicitly. The v2 baseline duplicates a borrowed implementation where it had no consuming method, allowing the final consuming implementation to retain the ID while changing only the ownership mechanism. From 3bd420808264771181733b7396fb789fbe66fee6 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 14:53:42 +0800 Subject: [PATCH 5/5] bench: preserve layout for renamed workloads --- benches/hot_path_baselines.rs | 4 ++-- benches/normalize.rs | 4 ++-- benches/support/workloads.rs | 4 ++-- benches/to_slash.rs | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/benches/hot_path_baselines.rs b/benches/hot_path_baselines.rs index 7acdf6f..0638c39 100644 --- a/benches/hot_path_baselines.rs +++ b/benches/hot_path_baselines.rs @@ -9,7 +9,7 @@ mod support; #[cfg(all(target_family = "unix", not(target_os = "cygwin")))] use support::workloads::RELATIVE_CASES; #[cfg(any(unix, windows))] -use support::workloads::{ROLLDOWN_ROOT, non_utf8_path}; +use support::workloads::{ROLLDOWN_ROOT, invalid_unicode_path}; fn bench_hot_path_baselines(criterion: &mut Criterion) { #[cfg(not(target_family = "windows"))] @@ -33,7 +33,7 @@ fn bench_hot_path_baselines(criterion: &mut Criterion) { #[cfg(any(unix, windows))] { - let non_utf8 = non_utf8_path(); + let non_utf8 = invalid_unicode_path(); let non_utf8_name = non_utf8.file_name().expect("non-UTF-8 fixture has a file name").to_owned(); let mut dirty_before_non_utf8 = non_utf8.clone(); dirty_before_non_utf8.pop(); diff --git a/benches/normalize.rs b/benches/normalize.rs index 5140b99..17ea0b8 100644 --- a/benches/normalize.rs +++ b/benches/normalize.rs @@ -7,7 +7,7 @@ use sugar_path::{SugarPath, SugarPathBuf}; mod support; #[cfg(any(unix, windows))] -use support::workloads::non_utf8_path; +use support::workloads::invalid_unicode_path; use support::workloads::{ CANONICAL_LEADING_PARENTS, CURRENT_DIRECTORY_CASES, DIRTY_PATHS, LEADING_PARENT_SCAN_CASES, PathCase, ROLLDOWN_PATHS, @@ -49,7 +49,7 @@ fn bench_normalize(criterion: &mut Criterion) { #[cfg(any(unix, windows))] { - let non_utf8 = non_utf8_path(); + let non_utf8 = invalid_unicode_path(); let mut group = criterion.benchmark_group("normalize/non_utf8"); group.throughput(Throughput::Bytes(non_utf8.as_os_str().len() as u64)); group.bench_function("non_utf8_lexically_clean", |bencher| { diff --git a/benches/support/workloads.rs b/benches/support/workloads.rs index 85cf8b3..5d94801 100644 --- a/benches/support/workloads.rs +++ b/benches/support/workloads.rs @@ -19,7 +19,7 @@ pub struct RelativeCase { } #[cfg(unix)] -pub fn non_utf8_path() -> PathBuf { +pub fn invalid_unicode_path() -> PathBuf { use std::os::unix::ffi::OsStringExt; PathBuf::from(OsString::from_vec( @@ -28,7 +28,7 @@ pub fn non_utf8_path() -> PathBuf { } #[cfg(windows)] -pub fn non_utf8_path() -> PathBuf { +pub fn invalid_unicode_path() -> PathBuf { use std::os::windows::ffi::OsStringExt; let mut units: Vec = diff --git a/benches/to_slash.rs b/benches/to_slash.rs index 923cfdc..5a26121 100644 --- a/benches/to_slash.rs +++ b/benches/to_slash.rs @@ -10,7 +10,7 @@ use support::workloads::ROLLDOWN_PATHS; #[cfg(target_family = "windows")] use support::workloads::WINDOWS_SLASH_CASES; #[cfg(any(unix, windows))] -use support::workloads::non_utf8_path; +use support::workloads::invalid_unicode_path; fn bench_to_slash(criterion: &mut Criterion) { let mut group = @@ -89,13 +89,13 @@ fn bench_to_slash(criterion: &mut Criterion) { #[cfg(any(unix, windows))] { - let non_utf8 = non_utf8_path(); - let mut group = criterion.benchmark_group("slash/non_utf8"); + let non_utf8 = invalid_unicode_path(); + let mut group = criterion.benchmark_group("slash/non_utf8_native"); group.throughput(Throughput::Bytes(non_utf8.as_os_str().len() as u64)); - group.bench_function("borrowed_receiver/non_utf8_fallible_result", |bencher| { + group.bench_function("borrowed_receiver/non_utf8_strict", |bencher| { bencher.iter(|| black_box(black_box(non_utf8.as_path()).try_to_slash())); }); - group.bench_function("borrowed_receiver/non_utf8_lossy_result", |bencher| { + group.bench_function("borrowed_native/non_utf8_lossy", |bencher| { bencher.iter(|| black_box(black_box(non_utf8.as_path()).to_slash_lossy())); }); group.finish();