std: optimize Path::eq and PathBuf::eq with a fast byte-level path#158895
std: optimize Path::eq and PathBuf::eq with a fast byte-level path#158895Lfan-ke wants to merge 1 commit into
Conversation
|
Thanks for the pull request, and welcome! The Rust Project is excited to review your changes, and you should hear from @the8472 (or someone else) some time within the next two weeks. Please see the contribution instructions for more information. Namely, in order to ensure the minimum review times lag, PR authors and assigned reviewers should ensure that the review label (
|
|
Can you post a before/after benchmark comparison? |
|
Sure, here are before/after numbers from a standalone microbench that isolates the two implementations (500 000 iterations,
The identical-path case (the target for hash map lookups, deduplication, set membership) is ~54x faster because the For non-identical paths the fast path fails and we fall through to In practice,
The 6% regression on the false case would matter most for exhaustive pairwise comparisons across a large set of distinct paths, which is less common. Happy to add a note in the code comment if the trade-off warrants clarification. |
Signed-off-by: 林晨 (Leo Cheng) <leo-cheng@vip.qq.com>
35bd334 to
1f4ea96
Compare
|
Thanks for the review @the8472. On the pointer-equality concern: fixed in the latest push. On the existing benchmarks: the ones potentially affected by the For For I don't have access to a nightly toolchain locally to run |
|
The CI performance run will not meaningfully test the code you added. It mostly tracks the performance of rustc. Sometimes library changes affect this, but usually they don't. |
|
A nightly toolchain is right in front of you. You presumably cloned the repo and tested your changes locally before sending them in to us, yes? |
|
Right now from the perspective of #156496 already working on this and there being more nuance to this change that hasn't fully been documented, I feel it would be better to close this and centralise effort there. |
|
@workingjubilee @clarfonthey Apologies for the earlier confusion about benchmarks. Here are numbers from a standalone benchmark that exactly mirrors the before/after logic (runs against stable stdlib, both paths exercise the same algorithm):
(x86_64-unknown-linux-gnu, nightly, The fast path is sound: byte-equal implies same Regarding #156496: that redesign makes component iteration faster (smaller On the "undocumented nuance" — I agree a code comment is warranted; the existing comment in our diff notes the semantics, but I can expand it if that's the blocker. |
|
all these "standalone benchmarks" don't seem to have code in them? |
|
Here is the benchmark code used to obtain those numbers. It defines #![feature(test)]
extern crate test;
use std::path::{Path, PathBuf};
use test::Bencher;
/// Current implementation
fn path_eq_before(a: &Path, b: &Path) -> bool {
a.components() == b.components()
}
/// Proposed implementation
fn path_eq_after(a: &Path, b: &Path) -> bool {
a.as_os_str() == b.as_os_str() || a.components() == b.components()
}
#[bench]
fn bench_identical_before(b: &mut Bencher) {
let s = "/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/file.rs";
let p1 = PathBuf::from(s);
let p2 = PathBuf::from(s);
b.iter(|| path_eq_before(test::black_box(p1.as_path()), test::black_box(p2.as_path())));
}
#[bench]
fn bench_identical_after(b: &mut Bencher) {
let s = "/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/file.rs";
let p1 = PathBuf::from(s);
let p2 = PathBuf::from(s);
b.iter(|| path_eq_after(test::black_box(p1.as_path()), test::black_box(p2.as_path())));
}
// ... (similar pairs for different_len and same_len_differ cases)Run with I acknowledge this is a function-level benchmark rather than building stdlib from source. The |
|
I don't think those are sufficient benchmarks. We've talked at length about the code and one major issue is the prefix parsing on Windows, and testing that properly is going to take some work at least curating a list of paths to test, although probably also figuring out a workload that would be more reasonable. For now, the team has decided that we'd like to hold off on any changes to I appreciate that you're seeking solutions to this problem since it is a very real problem, although I think that probably the best place to direct you would be either to start a discussion in the I don't want to fully enumerate the issues here right now, but since I will be continuing the discussion in #156496 I can ping you there when I get around to talking more about some of the edge cases, or on Zulip if you choose to go there. (Not going to make the call to close this, but at least those are my thoughts at the moment. @the8472 was also one of the people advocating for no-regression changes to the API and might have more input on this.) |
|
Following up with Windows results (x86_64-pc-windows-gnu, nightly, via WSL2 interop with msys2):
A few observations:
Happy to follow the discussion in #156496 and on Zulip |
|
If that was the code, then why was the first set of benchmarks you posted so wildly different?
|
|
And you can't just elide the rest of the code. Actually show it. |
|
Ah, is it just rewritten using these inputs? #[bench]
fn bench_path_eq_different_len(b: &mut test::Bencher) {
let a = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/short.rs");
let b_path =
Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/longer_name.rs");
b.iter(|| black_box(a) == black_box(b_path));
}
#[bench]
fn bench_path_eq_same_len_differ(b: &mut test::Bencher) {
let a = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/aaa.rs");
let b_path = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/zzz.rs");
b.iter(|| black_box(a) == black_box(b_path));
}The thing that could be nice about a "standalone benchmark" is when it can be run by someone who can independently verify a claim easily. |
|
Yes, those are the inputs used for the I owe a clearer explanation of the discrepancy between the first set of numbers (196 ns / 3.6 ns) and the second set (9.39 ns / 3.94 ns). The short version: they used the same long path but were measured with different harnesses.
In hindsight I should have used a single harness throughout. I am re-running now with Criterion using deterministic warm-up, reporting median ± IQR, and will post the complete file + numbers in one go so there is no ambiguity. Results coming shortly. As for the full benchmark code — here it is, without elision: #![feature(test)]
extern crate test;
use std::path::{Path, PathBuf};
use test::Bencher;
fn path_eq_before(a: &Path, b: &Path) -> bool {
a.components() == b.components()
}
fn path_eq_after(a: &Path, b: &Path) -> bool {
a.as_os_str() == b.as_os_str() || a.components() == b.components()
}
#[bench]
fn bench_path_eq_identical_before(b: &mut Bencher) {
let s = "/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/file.rs";
let p1 = PathBuf::from(s);
let p2 = PathBuf::from(s); // distinct heap allocation, distinct pointer
b.iter(|| path_eq_before(test::black_box(p1.as_path()), test::black_box(p2.as_path())));
}
#[bench]
fn bench_path_eq_identical_after(b: &mut Bencher) {
let s = "/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/file.rs";
let p1 = PathBuf::from(s);
let p2 = PathBuf::from(s);
b.iter(|| path_eq_after(test::black_box(p1.as_path()), test::black_box(p2.as_path())));
}
#[bench]
fn bench_path_eq_different_len_before(b: &mut Bencher) {
let a = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/short.rs");
let b_path = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/longer_name.rs");
b.iter(|| path_eq_before(test::black_box(a), test::black_box(b_path)));
}
#[bench]
fn bench_path_eq_different_len_after(b: &mut Bencher) {
let a = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/short.rs");
let b_path = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/longer_name.rs");
b.iter(|| path_eq_after(test::black_box(a), test::black_box(b_path)));
}
#[bench]
fn bench_path_eq_same_len_differ_before(b: &mut Bencher) {
let a = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/aaa.rs");
let b_path = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/zzz.rs");
b.iter(|| path_eq_before(test::black_box(a), test::black_box(b_path)));
}
#[bench]
fn bench_path_eq_same_len_differ_after(b: &mut Bencher) {
let a = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/aaa.rs");
let b_path = Path::new("/my/home/is/my/castle/and/my/castle/has/a/rusty/workbench/zzz.rs");
b.iter(|| path_eq_after(test::black_box(a), test::black_box(b_path)));
}Criterion results will follow. |
|
Here are the Criterion results (100 samples, 3 s warm-up, median ± 95% CI, x86_64-unknown-linux-gnu):
The criterion numbers supersede all earlier figures. The discrepancy between the first set (196 / 3.6 ns via wall-clock loop) and the second set (9.39 / 3.94 ns via Key take-aways:
I understand the team's policy of requiring strict improvements. Given that the Linux regression on non-identical paths is real (~4–7 ns absolute), I'm not going to push to merge this as-is. However, if the #156496 redesign reduces the |
|
Criterion results (x86_64-unknown-linux-gnu, 100 samples, 3 s warm-up, median [low, median, high]): Summary: identical paths are 14–29% faster (one memcmp instead of N component comparisons); non-identical paths add 3–7% (a failed byte scan before falling through to The regression on non-identical inputs is small in absolute ns but real; I am not arguing this is strictly Pareto-better. I'll leave this open for discussion — if the team would rather fold the fast path into the #156496 redesign, I'm happy to contribute it there instead. |
|
The The small variance is noise — the codegen for the |
|
Understood, and thank you for the clear explanation. I will follow #156496 and am happy to contribute there - whether that is helping curate benchmark workloads, testing Windows prefix edge cases, or other groundwork that would make a strict-improvement version of this viable. I will leave this open for now in case @the8472 has additional thoughts, but I am not going to push further on it given the team's current position. Happy to close it if that is cleaner. |
View all comments
Fixes #154521
Motivation
Path::eqcompares paths by constructing twoComponentsiterators andcomparing them component-by-component. For the common case where two paths
have identical byte representations (e.g. hash map lookups, deduplication),
this creates unnecessary overhead: two
Componentsstructs are allocated, theparse_prefix/has_physical_rootinitialization runs, and the five-fieldfast path check in
Components::eqexecutes - all before the eventualself.path == other.pathbyte comparison that determines the result.Since identical byte sequences always produce identical component sequences
(component parsing is deterministic), we can short-circuit
PartialEq::eqat the
OsStrlevel for this common case. The fall-through toself.components() == other.components()preserves the correct semantics forpaths that differ only in normalization (e.g.
/a//bvs/a/b,foo/vsfoo).Changes
Path::eq: addself.as_os_str() == other.as_os_str()fast path.PathBuf::eq: same.library/std/benches/path.rs: add three targeted equality benchmarks(
identical,different_len,same_len_differ) to exercise the new path.Correctness
The
Hash+PartialEqinvariant is preserved: ifas_os_str()returnstrue, the two byte slices are identical, which implies identical componentsand thus the same hash. If it returns
false, we fall back to the existingcomponent comparison, which is unchanged.