ci: add CodSpeed benchmarks for find and xargs (#703)

Introduce criterion benchmarks (via codspeed-criterion-compat) that drive
find and xargs end-to-end through their real entry points, plus a CodSpeed
GitHub Actions workflow to track performance on pushes and PRs.

- benches/find_bench.rs: walks a generated directory tree exercising the
  matcher tree (-name/-iname/-regex/-size/-type, grouping, -prune, -printf).
  Output is sent to a sink so the directory walk and matching dominate.
- benches/xargs_bench.rs: feeds a corpus via -a and runs 'true' so xargs'
  own argument reading/splitting/batching dominates (whitespace, NUL, -n, -s).
- .github/workflows/codspeed.yml: mirrors the uutils/grep setup.
This commit is contained in:
Sylvestre Ledru
2026-06-06 10:37:56 +02:00
committed by GitHub
parent c5ff1bbb6b
commit 33703b79d8
5 changed files with 676 additions and 11 deletions
+37
View File
@@ -0,0 +1,37 @@
name: CodSpeed
on:
push:
branches:
- "main"
pull_request:
# `workflow_dispatch` allows CodSpeed to trigger backtest
# performance analysis in order to generate initial data.
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
codspeed:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust toolchain, cache and cargo-codspeed binary
uses: moonrepo/setup-rust@v0
with:
channel: stable
cache-target: release
bins: cargo-codspeed
- name: Build the benchmark target(s)
run: cargo codspeed build
- name: Run the benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: cargo codspeed run
Generated
+387 -11
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -23,6 +23,7 @@ itertools = "0.14.0"
thiserror = "2.0.12"
[dev-dependencies]
criterion = { version = "4.7.0", package = "codspeed-criterion-compat" }
assert_cmd = "2"
ctor = "1.0"
filetime = "0.2"
@@ -31,6 +32,14 @@ rstest = "0.25.0"
tempfile = "3"
uutests = "0.9.0"
[[bench]]
name = "find_bench"
harness = false
[[bench]]
name = "xargs_bench"
harness = false
[[bin]]
name = "find"
path = "src/find/main.rs"
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2024 the uutils developers
//
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
//! End-to-end benchmarks for `find`, run through the real `find_main` entry
//! point so they exercise argument parsing, the matcher tree and the directory
//! walk together. Output is sent to a sink so the benchmarks measure the work
//! `find` does rather than terminal I/O.
use std::cell::RefCell;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use findutils::find::{find_main, Dependencies};
/// `Dependencies` implementation that throws output away. A fixed `now` keeps
/// time-based matchers (`-newer`, `-mtime`, …) deterministic across runs.
struct SinkDependencies {
output: RefCell<io::Sink>,
now: SystemTime,
}
impl SinkDependencies {
fn new() -> Self {
Self {
output: RefCell::new(io::sink()),
now: SystemTime::now(),
}
}
}
impl Dependencies for SinkDependencies {
fn get_output(&self) -> &RefCell<dyn Write> {
&self.output
}
fn now(&self) -> SystemTime {
self.now
}
fn confirm(&self, _prompt: &str) -> bool {
false
}
}
/// Run `find` end-to-end. `args` are the arguments after the program name
/// (flags, paths, expression). The exit status is ignored — we only care about
/// the work performed.
fn run(args: &[&str]) {
let mut argv: Vec<&str> = Vec::with_capacity(args.len() + 1);
argv.push("find");
argv.extend_from_slice(args);
let deps = SinkDependencies::new();
let _ = find_main(&argv, &deps);
}
/// Build a moderately deep directory tree to walk. `depth` levels, each holding
/// `dirs` sub-directories and `files` files. File names vary so name/regex
/// matchers have something to discriminate on, and sizes vary so `-size` does
/// real work. Returns the root directory.
fn build_tree(depth: u32, dirs: u32, files: u32) -> PathBuf {
let root = std::env::temp_dir().join(format!("uu_find_bench_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
populate(&root, depth, dirs, files, 0);
root
}
fn populate(dir: &Path, depth: u32, dirs: u32, files: u32, seed: u32) {
for f in 0..files {
let n = seed.wrapping_add(f);
// A mix of extensions and a sprinkling of a rare marker name.
let name = match n % 5 {
0 => format!("module_{n}.rs"),
1 => format!("data_{n}.txt"),
2 => format!("image_{n}.png"),
3 => format!("README_{n}.md"),
_ if n % 500 == 0 => format!("RAREHIT_{n}.log"),
_ => format!("file_{n}.log"),
};
// Sizes from a few bytes up to ~8 KiB so -size buckets differ.
let size = (n as usize % 8192) + 1;
let path = dir.join(name);
std::fs::write(&path, vec![b'x'; size]).unwrap();
}
if depth == 0 {
return;
}
for d in 0..dirs {
let sub = dir.join(format!("dir_{d}"));
std::fs::create_dir_all(&sub).unwrap();
populate(
&sub,
depth - 1,
dirs,
files,
seed.wrapping_add((d + 1) * 31),
);
}
}
fn bench_e2e(c: &mut Criterion) {
// depth 4, 4 dirs/level, 25 files/dir → a few thousand entries.
let root = build_tree(4, 4, 25);
let dir = root.to_str().unwrap();
let mut group = c.benchmark_group("find");
// Plain full walk with the implicit -print.
group.bench_function("walk_all", |b| {
b.iter(|| run(black_box(&[dir])));
});
// Filter by file type only.
group.bench_function("type_f", |b| {
b.iter(|| run(black_box(&[dir, "-type", "f"])));
});
// Glob name match — a common invocation.
group.bench_function("name_glob", |b| {
b.iter(|| run(black_box(&[dir, "-name", "*.rs"])));
});
// Case-insensitive name match.
group.bench_function("iname_glob", |b| {
b.iter(|| run(black_box(&[dir, "-iname", "*.RS"])));
});
// Regex over the whole path.
group.bench_function("regex_path", |b| {
b.iter(|| run(black_box(&[dir, "-regex", r".*/module_[0-9]+\.rs"])));
});
// Size predicate forces a stat per entry.
group.bench_function("size_gt", |b| {
b.iter(|| run(black_box(&[dir, "-type", "f", "-size", "+4k"])));
});
// Combined predicate with AND/OR and grouping.
group.bench_function("combined_expr", |b| {
b.iter(|| {
run(black_box(&[
dir, "-type", "f", "(", "-name", "*.rs", "-o", "-name", "*.md", ")",
]));
});
});
// Prune whole subtrees, then print the rest.
group.bench_function("prune", |b| {
b.iter(|| {
run(black_box(&[
dir, "-name", "dir_0", "-prune", "-o", "-type", "f", "-print",
]));
});
});
// -printf with several directives exercises the formatter.
group.bench_function("printf", |b| {
b.iter(|| run(black_box(&[dir, "-type", "f", "-printf", "%p %s %y\\n"])));
});
group.finish();
let _ = std::fs::remove_dir_all(&root);
}
criterion_group!(benches, bench_e2e);
criterion_main!(benches);
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2024 the uutils developers
//
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.
//! End-to-end benchmarks for `xargs`, run through the real `xargs_main` entry
//! point. Input is supplied via `-a <file>` (rather than stdin) so the harness
//! can feed a fixed corpus without touching the process's stdin, and the
//! command is `true` so the measurement is dominated by `xargs`'s own work
//! (reading, splitting and batching arguments) rather than the child.
use std::path::PathBuf;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use findutils::xargs::xargs_main;
/// Run `xargs` end-to-end. `args` are the arguments after the program name.
/// The exit status is ignored — we only care about the work performed.
fn run(args: &[&str]) {
let mut argv: Vec<&str> = Vec::with_capacity(args.len() + 1);
argv.push("xargs");
argv.extend_from_slice(args);
let _ = xargs_main(&argv);
}
/// Write `count` short tokens to a temp file, joined by `sep`. Returns the path.
fn build_input(count: u32, sep: &str, tag: &str) -> PathBuf {
let mut content = String::new();
for i in 0..count {
if i > 0 {
content.push_str(sep);
}
content.push_str(&format!("item_{i}"));
}
let path = std::env::temp_dir().join(format!("uu_xargs_bench_{}_{tag}", std::process::id()));
std::fs::write(&path, content).unwrap();
path
}
fn bench_e2e(c: &mut Criterion) {
// Whitespace- and newline-separated corpora, plus a NUL-separated one for
// the `-0` path. A few thousand short tokens keeps parsing dominant while
// `true` batches stay to a handful of (untraced) spawns.
let ws = build_input(4000, " ", "ws");
let nul = build_input(4000, "\0", "nul");
let ws_path = ws.to_str().unwrap();
let nul_path = nul.to_str().unwrap();
let mut group = c.benchmark_group("xargs");
// Default whitespace splitting, single batch (fits one command line).
group.bench_function("split_whitespace", |b| {
b.iter(|| run(black_box(&["-a", ws_path, "true"])));
});
// NUL-delimited input (`find -print0 | xargs -0` shape).
group.bench_function("split_null", |b| {
b.iter(|| run(black_box(&["-0", "-a", nul_path, "true"])));
});
// Cap arguments per command with -n, exercising the batching path across
// several (untraced) invocations.
group.bench_function("batched_n", |b| {
b.iter(|| run(black_box(&["-a", ws_path, "-n", "1000", "true"])));
});
// Bound each command line by size with -s, another batching trigger.
group.bench_function("batched_size", |b| {
b.iter(|| run(black_box(&["-a", ws_path, "-s", "4096", "true"])));
});
group.finish();
let _ = std::fs::remove_file(&ws);
let _ = std::fs::remove_file(&nul);
}
criterion_group!(benches, bench_e2e);
criterion_main!(benches);