//! File discovery and incremental matching workloads used by Python tools.
#![feature(test)]
extern crate test;
use std::{
fs,
hint::black_box,
path::{Path, PathBuf},
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use ignore::{
DirEntry, Error, ParallelVisitor, ParallelVisitorBuilder, WalkBuilder,
WalkState, gitignore::GitignoreBuilder, overrides::OverrideBuilder,
types::TypesBuilder,
};
const RULES: &str = "\
.venv/
__pycache__/
*.py[cod]
*.egg-info/
/build/
/dist/
*.log
";
/// A deterministic source tree, created before timing starts.
struct Workspace {
temp: PathBuf,
root: PathBuf,
paths: Vec<PathBuf>,
python_files: usize,
}
impl Workspace {
fn new(packages: usize, depth: usize, files: usize, nested: bool) -> Self {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let temp = std::env::temp_dir().join(format!(
"ignore-bench-{:x}-{}",
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(),
NEXT.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&temp).unwrap();
let root = temp.join("workspace");
let mut ws = Workspace { temp, root, paths: vec![], python_files: 0 };
ws.write(".git/info/exclude", "private/\n");
ws.write(".gitignore", RULES);
ws.write(".ignore", ".git/\n*.tmp\n");
ws.write(".venv/lib/python3.13/site-packages/dependency.py", "");
ws.write("private/internal.py", "");
ws.write("build/generated.py", "");
for package in 0..packages {
let base = format!("packages/pkg{package}");
ws.write(&format!("{base}/pyproject.toml"), "[project]\n");
if nested {
ws.write(
&format!("{base}/.gitignore"),
"generated_*.py\n!generated_keep.py\n",
);
}
let mut dir = base;
for level in 0..depth {
dir.push_str(&format!("/level{level}"));
for file in 0..files {
let path = format!("{dir}/module{file}.py");
ws.write(&path, "");
ws.paths.push(PathBuf::from(path));
ws.python_files += 1;
}
ws.write(&format!("{dir}/__pycache__/module.pyc"), "");
ws.write(&format!("{dir}/notes.txt"), "");
if nested {
ws.write(&format!("{dir}/generated_drop.py"), "");
ws.write(&format!("{dir}/generated_keep.py"), "");
ws.python_files += 1;
}
}
}
ws
}
fn write(&self, relative: &str, contents: &str) {
let path = self.root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
fn builder(&self) -> WalkBuilder {
let mut builder = WalkBuilder::new(&self.root);
// Keep parent discovery, but do not depend on the user's Git config.
builder.current_dir(&self.root).hidden(false).git_global(false);
builder
}
}
impl Drop for Workspace {
fn drop(&mut self) {
fs::remove_dir_all(&self.temp).unwrap();
}
}
fn is_python(entry: &DirEntry) -> bool {
entry.file_type().is_some_and(|ft| ft.is_file())
&& entry.path().extension().is_some_and(|ext| ext == "py")
}
/// Accumulate per worker, as Ruff does, instead of contending on every file.
struct CountBuilder<'a>(&'a Mutex<usize>);
impl<'s> ParallelVisitorBuilder<'s> for CountBuilder<'s> {
fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
Box::new(CountVisitor { total: self.0, count: 0 })
}
}
struct CountVisitor<'a> {
total: &'a Mutex<usize>,
count: usize,
}
impl ParallelVisitor for CountVisitor<'_> {
fn visit(&mut self, entry: Result<DirEntry, Error>) -> WalkState {
let entry = entry.unwrap();
assert!(entry.error().is_none());
self.count += usize::from(is_python(&entry));
WalkState::Continue
}
}
impl Drop for CountVisitor<'_> {
fn drop(&mut self) {
*self.total.lock().unwrap() += self.count;
}
}
fn walk(builder: &WalkBuilder, threads: usize) -> usize {
if threads == 0 {
builder
.build()
.map(|entry| {
let entry = entry.unwrap();
assert!(entry.error().is_none());
usize::from(is_python(&entry))
})
.sum()
} else {
let total = Mutex::new(0);
let mut counter = CountBuilder(&total);
builder.clone().threads(threads).build_parallel().visit(&mut counter);
total.into_inner().unwrap()
}
}
macro_rules! walk_bench {
($name:ident, $packages:expr, $depth:expr, $files:expr, $nested:expr, $threads:expr) => {
#[bench]
fn $name(b: &mut test::Bencher) {
let ws = Workspace::new($packages, $depth, $files, $nested);
let builder = ws.builder();
assert_eq!(walk(&builder, $threads), ws.python_files);
b.iter(|| {
assert_eq!(
walk(black_box(&builder), $threads),
ws.python_files
)
});
}
};
}
walk_bench!(walk_small_serial, 4, 2, 8, false, 0);
walk_bench!(walk_small_parallel_12, 4, 2, 8, false, 12);
walk_bench!(walk_wide_serial, 128, 1, 32, false, 0);
walk_bench!(walk_wide_parallel_1, 128, 1, 32, false, 1);
walk_bench!(walk_wide_parallel_4, 128, 1, 32, false, 4);
walk_bench!(walk_wide_parallel_12, 128, 1, 32, false, 12);
walk_bench!(walk_deep_serial, 16, 12, 16, false, 0);
walk_bench!(walk_deep_parallel_12, 16, 12, 16, false, 12);
walk_bench!(walk_nested_serial, 64, 3, 16, true, 0);
walk_bench!(walk_nested_parallel_12, 64, 3, 16, true, 12);
#[bench]
fn walk_unfiltered_parallel_12(b: &mut test::Bencher) {
let ws = Workspace::new(128, 1, 32, false);
let mut builder = ws.builder();
builder.standard_filters(false);
let expected = ws.python_files + 3;
assert_eq!(walk(&builder, 12), expected);
b.iter(|| assert_eq!(walk(black_box(&builder), 12), expected));
}
#[bench]
fn walk_explicit_files(b: &mut test::Bencher) {
let ws = Workspace::new(64, 1, 8, false);
let mut builder = WalkBuilder::from_iter(
ws.paths.iter().map(|relative| ws.root.join(relative)),
);
builder.current_dir(&ws.root).hidden(false).git_global(false).threads(12);
b.iter(|| assert_eq!(walk(black_box(&builder), 12), ws.python_files));
}
#[bench]
fn uv_ignored_workspace_member(b: &mut test::Bencher) {
let ws = Workspace::new(1, 1, 0, false);
let member = ws.root.join("packages/pkg0/level0");
fs::remove_file(member.join("notes.txt")).unwrap();
let mut builder = WalkBuilder::new(&member);
builder.hidden(false).git_global(false).current_dir(&ws.root);
b.iter(|| {
// uv stops as soon as it encounters a non-directory entry.
assert!(!builder.build().any(|entry| !entry.unwrap().path().is_dir()));
});
}
#[bench]
fn uv_nonempty_workspace_member(b: &mut test::Bencher) {
let ws = Workspace::new(1, 1, 32, false);
let member = ws.root.join("packages/pkg0/level0");
let mut builder = WalkBuilder::new(&member);
builder.hidden(false).git_global(false).current_dir(&ws.root);
b.iter(|| {
assert!(builder.build().any(|entry| !entry.unwrap().path().is_dir()));
});
}
#[bench]
fn uv_scripts_source_archive(b: &mut test::Bencher) {
let ws = Workspace::new(64, 3, 16, true);
fs::remove_dir_all(ws.root.join(".git")).unwrap();
let mut builder = ws.builder();
builder.require_git(false);
// Without .git/info/exclude, private/internal.py is also visible.
let expected = ws.python_files + 1;
assert_eq!(walk(&builder, 0), expected);
b.iter(|| assert_eq!(walk(black_box(&builder), 0), expected));
}
#[bench]
fn incremental_cold(b: &mut test::Bencher) {
let ws = Workspace::new(16, 4, 4, true);
let builder = ws.builder();
b.iter(|| {
let mut matchers = builder.build_matchers();
for path in &ws.paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
#[bench]
fn incremental_warm(b: &mut test::Bencher) {
let ws = Workspace::new(16, 4, 4, true);
let mut matchers = ws.builder().build_matchers();
for path in &ws.paths {
assert!(!matchers[0].matched(path, false).is_ignore());
}
b.iter(|| {
for path in &ws.paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
#[bench]
fn incremental_warm_noncanonical(b: &mut test::Bencher) {
let ws = Workspace::new(16, 4, 4, true);
let paths: Vec<_> = ws
.paths
.iter()
.map(|path| {
Path::new("packages/.")
.join(path.strip_prefix("packages").unwrap())
})
.collect();
let mut matchers = ws.builder().build_matchers();
for path in &paths {
assert!(!matchers[0].matched(path, false).is_ignore());
}
b.iter(|| {
for path in &paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
#[bench]
fn incremental_ignored(b: &mut test::Bencher) {
let ws = Workspace::new(0, 0, 0, false);
let mut matchers = ws.builder().build_matchers();
let path = Path::new(".venv/lib/python3.13/site-packages/dependency.py");
assert!(matchers[0].matched(path, false).is_ignore());
b.iter(|| {
assert!(matchers[0].matched(black_box(path), false).is_ignore())
});
}
#[bench]
fn build_empty_gitignore(b: &mut test::Bencher) {
let builder = GitignoreBuilder::new("workspace/packages/example");
b.iter(|| black_box(builder.build().unwrap()));
}
#[bench]
fn build_single_directory_whitelist(b: &mut test::Bencher) {
let mut builder = GitignoreBuilder::new("workspace");
builder.add_line(None, "!/.github/").unwrap();
b.iter(|| black_box(builder.build().unwrap()));
}
#[bench]
fn build_python_gitignore(b: &mut test::Bencher) {
let mut builder = GitignoreBuilder::new("workspace");
for line in RULES.lines() {
builder.add_line(None, line).unwrap();
}
b.iter(|| black_box(builder.build().unwrap()));
}
#[bench]
fn match_python_gitignore(b: &mut test::Bencher) {
let mut builder = GitignoreBuilder::new("workspace");
for line in RULES.lines() {
builder.add_line(None, line).unwrap();
}
let matcher = builder.build().unwrap();
b.iter(|| {
assert!(
matcher
.matched(black_box("workspace/pkg/mod.py"), false)
.is_none()
);
assert!(
matcher
.matched(black_box("workspace/pkg/mod.pyc"), false)
.is_ignore()
);
assert!(
matcher
.matched(black_box("workspace/pkg/__pycache__"), true)
.is_ignore()
);
});
}
#[bench]
fn incremental_warm_deep(b: &mut test::Bencher) {
let ws = Workspace::new(16, 12, 4, true);
let mut matchers = ws.builder().build_matchers();
for path in &ws.paths {
assert!(!matchers[0].matched(path, false).is_ignore());
}
b.iter(|| {
for path in &ws.paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
#[bench]
fn incremental_ignored_nested(b: &mut test::Bencher) {
let ws = Workspace::new(1, 1, 1, false);
let path = Path::new(
"packages/pkg0/level0/.venv/lib/python3.13/site-packages/dependency.py",
);
ws.write(path.to_str().unwrap(), "");
let mut matchers = ws.builder().build_matchers();
assert!(matchers[0].matched(path, false).is_ignore());
b.iter(|| {
assert!(matchers[0].matched(black_box(path), false).is_ignore())
});
}
#[bench]
fn incremental_ignored_parent(b: &mut test::Bencher) {
let ws = Workspace::new(0, 0, 0, false);
let mut matchers = ws.builder().build_matchers();
let path = Path::new(".venv/dependency.py");
assert!(matchers[0].matched(path, false).is_ignore());
b.iter(|| {
assert!(matchers[0].matched(black_box(path), false).is_ignore())
});
}
/// Create a deep tree with ignore rules at every package level.
fn dense_ignore_workspace() -> Workspace {
let ws = Workspace::new(16, 12, 16, true);
for package in 0..16 {
let mut dir = format!("packages/pkg{package}");
for level in 0..12 {
dir.push_str(&format!("/level{level}"));
ws.write(
&format!("{dir}/.gitignore"),
"generated_*.py\n!generated_keep.py\n",
);
}
}
ws
}
fn dense_ignore_walk(b: &mut test::Bencher, threads: usize) {
let ws = dense_ignore_workspace();
let builder = ws.builder();
assert_eq!(walk(&builder, threads), ws.python_files);
b.iter(|| assert_eq!(walk(black_box(&builder), threads), ws.python_files));
}
#[bench]
fn walk_dense_ignore_serial(b: &mut test::Bencher) {
dense_ignore_walk(b, 0);
}
#[bench]
fn walk_dense_ignore_parallel_12(b: &mut test::Bencher) {
dense_ignore_walk(b, 12);
}
#[bench]
fn incremental_dense_ignore_cold(b: &mut test::Bencher) {
let ws = dense_ignore_workspace();
let builder = ws.builder();
b.iter(|| {
let mut matchers = builder.build_matchers();
for path in &ws.paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
#[bench]
fn incremental_dense_ignore_warm(b: &mut test::Bencher) {
let ws = dense_ignore_workspace();
let mut matchers = ws.builder().build_matchers();
for path in &ws.paths {
assert!(!matchers[0].matched(path, false).is_ignore());
}
b.iter(|| {
for path in &ws.paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
fn multiple_roots_walk(b: &mut test::Bencher, threads: usize) {
let ws = Workspace::new(64, 1, 32, false);
let mut builder = WalkBuilder::new(ws.root.join("packages/pkg0"));
builder.current_dir(&ws.root).hidden(false).git_global(false);
for package in 1..64 {
builder.add(ws.root.join(format!("packages/pkg{package}")));
}
assert_eq!(walk(&builder, threads), ws.python_files);
b.iter(|| assert_eq!(walk(black_box(&builder), threads), ws.python_files));
}
#[bench]
fn walk_multiple_roots_parallel_1(b: &mut test::Bencher) {
multiple_roots_walk(b, 1);
}
#[bench]
fn walk_multiple_roots_parallel_12(b: &mut test::Bencher) {
multiple_roots_walk(b, 12);
}
#[bench]
fn walk_with_overrides_and_types(b: &mut test::Bencher) {
let ws = Workspace::new(64, 3, 16, true);
let mut overrides = OverrideBuilder::new(&ws.root);
overrides.add("!*.txt").unwrap();
let mut types = TypesBuilder::new();
types.add("python", "*.py").unwrap();
types.select("python");
let mut builder = ws.builder();
builder
.overrides(overrides.build().unwrap())
.types(types.build().unwrap());
assert_eq!(walk(&builder, 12), ws.python_files);
b.iter(|| assert_eq!(walk(black_box(&builder), 12), ws.python_files));
}
#[bench]
fn match_single_file_glob(b: &mut test::Bencher) {
let mut builder = GitignoreBuilder::new("");
builder.add_line(None, "*.pyc").unwrap();
let matcher = builder.build().unwrap();
let paths = [
("module.py", false),
("module.pyc", true),
("src/module.py", false),
("src/__pycache__/module.pyc", true),
];
b.iter(|| {
for (path, ignored) in paths {
assert_eq!(
matcher.matched(black_box(path), false).is_ignore(),
ignored
);
}
});
}
#[bench]
fn match_single_directory_whitelist(b: &mut test::Bencher) {
let mut builder = GitignoreBuilder::new("");
builder.add_line(None, "!/.github/").unwrap();
let matcher = builder.build().unwrap();
let paths = [
(".github", true, true),
(".github", false, false),
(".github/workflows/check.yml", false, false),
("src/module.py", false, false),
("src", true, false),
];
b.iter(|| {
for (path, is_dir, whitelisted) in paths {
assert_eq!(
matcher.matched(black_box(path), is_dir).is_whitelist(),
whitelisted
);
}
});
}
#[bench]
fn match_single_path_whitelist(b: &mut test::Bencher) {
let mut builder = GitignoreBuilder::new("");
builder.add_line(None, "!/vendor/typeshed/**/*").unwrap();
let matcher = builder.build().unwrap();
let paths = [
("vendor/typeshed/stdlib/builtins.pyi", true),
("vendor/typeshed/stubs/pkg/__init__.pyi", true),
("src/lib.rs", false),
("vendor/other/module.py", false),
];
b.iter(|| {
for (path, whitelisted) in paths {
assert_eq!(
matcher.matched(black_box(path), false).is_whitelist(),
whitelisted
);
}
});
}
#[bench]
fn walk_gitignore_only_parallel_12(b: &mut test::Bencher) {
let ws = Workspace::new(128, 1, 32, false);
fs::remove_file(ws.root.join(".ignore")).unwrap();
fs::remove_file(ws.root.join(".git/info/exclude")).unwrap();
let builder = ws.builder();
// The private module is visible without .git/info/exclude.
let expected = ws.python_files + 1;
assert_eq!(walk(&builder, 12), expected);
b.iter(|| assert_eq!(walk(black_box(&builder), 12), expected));
}
#[bench]
fn incremental_gitignore_only(b: &mut test::Bencher) {
let ws = Workspace::new(16, 4, 4, false);
fs::remove_file(ws.root.join(".ignore")).unwrap();
fs::remove_file(ws.root.join(".git/info/exclude")).unwrap();
let mut matchers = ws.builder().build_matchers();
for path in &ws.paths {
assert!(!matchers[0].matched(path, false).is_ignore());
}
b.iter(|| {
for path in &ws.paths {
assert!(!matchers[0].matched(black_box(path), false).is_ignore());
}
});
}
#[bench]
fn walk_selected_python_files(b: &mut test::Bencher) {
let ws = Workspace::new(128, 1, 32, false);
let mut overrides = OverrideBuilder::new(&ws.root);
overrides.add("*.py").unwrap().add("*.pyi").unwrap();
let mut builder = ws.builder();
builder.overrides(overrides.build().unwrap());
assert_eq!(walk(&builder, 12), ws.python_files);
b.iter(|| assert_eq!(walk(black_box(&builder), 12), ws.python_files));
}