rust: faster filter path/glob evaluation; port filepath.Match; interleaved bench runners

This commit is contained in:
Andy Dixon
2026-10-08 18:12:36 +01:00
parent aa738cc8a1
commit a80e791902
4 changed files with 277 additions and 29 deletions
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Runs the Go `go test -bench` suite and the Rust bench example
interleaved for several rounds and prints the best time per op of each.
rust/bench/micro.py GO_TUI_TEST GO_TREEMAP_TEST RUST_BENCH [ROUNDS]
Build the inputs with `go test -c ./internal/tui`, `go test -c
./internal/treemap` and `cargo build --release --example bench`. The Go tui
test binary must run from internal/tui, so run this from the repo root.
"""
import re, subprocess, sys
go_tui, go_tm, rust = sys.argv[1:4]
rounds = int(sys.argv[4]) if len(sys.argv) > 4 else 5
names = { # rust name -> go name
"treemap/Squarify(300)": "Squarify", "treemap/Neighbour(300)": "Neighbour",
"tui/BigColdFrame": "BigColdFrame", "tui/BigResize": "BigResize",
"tui/BigFlatDirCold": "BigFlatDirCold", "tui/BigNavigate": "BigNavigate",
"tui/BigFilter/size > 1MB": "BigFilter/size_>_1MB",
"tui/BigFilter/*.dat AND size > 500k": "BigFilter/*.dat_AND_size_>_500k",
"tui/BigFilter/path contains sub3": "BigFilter/path_contains_sub3",
}
unit = {"ns": 1e-9, "µs": 1e-6, "ms": 1e-3, "s": 1}
go_best, rust_best = {}, {}
def keep(d, k, v):
d[k] = min(d.get(k, v), v)
for _ in range(rounds):
for exe, cwd in ((go_tui, "internal/tui"), (go_tm, "internal/treemap")):
out = subprocess.run([exe, "-test.run=NONE", "-test.bench=.", "-test.benchtime=1s"], cwd=cwd, capture_output=True, text=True).stdout
for m in re.finditer(r"^Benchmark(\S+?)-\d+\s+\d+\s+([\d.]+) ns/op", out, re.M):
keep(go_best, m[1], float(m[2]) * 1e-9)
out = subprocess.run([rust], capture_output=True, text=True).stdout
for m in re.finditer(r"^(\S.*?)\s+\d+ ops\s+([\d.]+)(ns|µs|ms|s)/op", out, re.M):
keep(rust_best, m[1].strip(), float(m[2]) * unit[m[3]])
def fmt(s):
return f"{s * 1e6:.1f} µs" if s < 1e-3 else f"{s * 1e3:.2f} ms"
print(f"Best of {rounds} interleaved rounds\n")
print("| benchmark | Go | Rust | speed-up |\n|---|---:|---:|---:|")
for r, g in names.items():
if r in rust_best and g in go_best:
print(f"| {r} | {fmt(go_best[g])} | {fmt(rust_best[r])} | {go_best[g] / rust_best[r]:.2f}× |")
+5 -6
View File
@@ -2,7 +2,6 @@ use super::eval::Ctx;
use super::parser::Query;
use crate::cancel::Cancel;
use crate::inventory::{NodeId, SizeMode, Tree, FLAG_HARDLINK_DUP};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -45,7 +44,7 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<Fi
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64);
let mut c = Ctx::new(t, 0, now);
if q.uses_path {
c.dir_paths = Some(HashMap::new());
c.dir_paths = Some(vec![String::new(); n]);
}
for i in 0..n {
if i & 0xffff == 0 && cancel.is_cancelled() {
@@ -53,11 +52,11 @@ pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<Fi
}
let id = i as NodeId;
let nd = t.node(id);
(c.id, c.node, c.path) = (id, nd, None);
c.reset(id);
if c.dir_paths.is_some() && nd.is_dir() {
let p = c.lower_path();
c.dir_paths.as_mut().unwrap().insert(id, p.clone());
c.path = Some(p);
c.fill_path();
let p = c.path.clone();
c.dir_paths.as_mut().unwrap()[i] = p;
}
if i > 0 && q.matches(&mut c) {
matched[i] = true;
+46 -20
View File
@@ -3,7 +3,6 @@ use crate::inventory::{Flags, Node, NodeId, Tree};
use crate::platform;
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashMap;
/// The evaluation context for one node. The tree must be read-locked.
pub struct Ctx<'a> {
@@ -11,26 +10,51 @@ pub struct Ctx<'a> {
pub id: NodeId,
pub node: &'a Node,
pub now: i64, // unix nanoseconds
pub(super) path: Option<String>,
// dir_paths caches lower-cased directory paths during apply, which visits
// parents before children, so a node's path is one concatenation.
pub(super) dir_paths: Option<HashMap<NodeId, String>>,
// The lower-cased path of the node once has_path is set; the buffers are
// reused across nodes so the per-node cost is one copy, not allocations.
pub(super) path: String,
pub(super) has_path: bool,
name: String,
// dir_paths caches lower-cased directory paths (by NodeId; empty for
// files) during apply, which visits parents before children, so a node's
// path is one concatenation.
pub(super) dir_paths: Option<Vec<String>>,
}
/// Appends name lower-cased, without allocating for ASCII names.
fn push_lower(buf: &mut String, name: &[u8]) {
if name.is_ascii() {
buf.extend(name.iter().map(|b| b.to_ascii_lowercase() as char));
} else {
buf.push_str(&String::from_utf8_lossy(name).to_lowercase());
}
}
impl<'a> Ctx<'a> {
pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> {
Ctx { tree, id, node: tree.node(id), now, path: None, dir_paths: None }
Ctx { tree, id, node: tree.node(id), now, path: String::new(), has_path: false, name: String::new(), dir_paths: None }
}
/// Moves the context to another node.
pub(super) fn reset(&mut self, id: NodeId) {
self.id = id;
self.node = self.tree.node(id);
self.has_path = false;
}
fn str(&mut self, f: Field) -> Cow<'_, str> {
let n = self.node;
match f {
Field::Name => n.name_str().to_lowercase().into(),
Field::Path => {
if self.path.is_none() {
self.path = Some(self.lower_path());
Field::Name => {
self.name.clear();
push_lower(&mut self.name, &n.name);
self.name.as_str().into()
}
self.path.as_deref().unwrap().into()
Field::Path => {
if !self.has_path {
self.fill_path();
}
self.path.as_str().into()
}
Field::Ext => self.tree.ext_name(n).into(),
Field::Type => n.kind.name().to_lowercase().into(),
@@ -54,17 +78,19 @@ impl<'a> Ctx<'a> {
}
}
pub(super) fn lower_path(&self) -> String {
if let Some(dp) = &self.dir_paths {
if let Some(p) = dp.get(&self.id) {
return p.clone();
/// Computes the lower-cased path into self.path.
pub(super) fn fill_path(&mut self) {
self.has_path = true;
self.path.clear();
if let Some(pp) = self.dir_paths.as_ref().and_then(|dp| dp.get(self.node.parent as usize)).filter(|p| !p.is_empty()) {
self.path.push_str(pp);
if !pp.ends_with('/') {
self.path.push('/');
}
if let Some(pp) = dp.get(&self.node.parent) {
let sep = if pp.ends_with('/') { "" } else { "/" };
return format!("{pp}{sep}{}", self.node.name_str().to_lowercase());
push_lower(&mut self.path, &self.node.name);
return;
}
}
self.tree.path_string(self.id).to_lowercase()
self.path.push_str(&self.tree.path_string(self.id).to_lowercase());
}
}
+182 -4
View File
@@ -644,11 +644,179 @@ mod tests {
}
}
/// Shell-style glob match with Go filepath.Match semantics: `*` and `?` do
/// not match '/'. Unlike Go, glibc treats a malformed bracket literally.
/// Shell-style glob match, a port of Go's filepath.Match: `*` and `?` do not
/// match '/', `[^...]` negates, `\\` escapes. A malformed pattern matches
/// nothing.
pub fn glob_match(pattern: &[u8], name: &[u8]) -> bool {
let (Ok(p), Ok(n)) = (CString::new(pattern), CString::new(name)) else { return false };
unsafe { libc::fnmatch(p.as_ptr(), n.as_ptr(), libc::FNM_PATHNAME) == 0 }
// Fast path for the common "*.ext" shape: * cannot cross '/'.
if let Some(suffix) = pattern.strip_prefix(b"*") {
if !suffix.iter().any(|b| b"*?[\\".contains(b)) {
return name.ends_with(suffix) && !name[..name.len() - suffix.len()].contains(&b'/');
}
}
glob(pattern, name).unwrap_or(false)
}
struct BadPattern;
fn decode_rune(s: &[u8]) -> (u32, usize) {
match s.utf8_chunks().next() {
Some(c) if !c.valid().is_empty() => {
let ch = c.valid().chars().next().unwrap();
(ch as u32, ch.len_utf8())
}
_ => (0xFFFD, 1),
}
}
fn glob(mut pattern: &[u8], mut name: &[u8]) -> Result<bool, BadPattern> {
'pattern: while !pattern.is_empty() {
let (star, chunk, rest) = scan_chunk(pattern);
pattern = rest;
if star && chunk.is_empty() {
// Trailing * matches the rest of the string unless it has a /.
return Ok(!name.contains(&b'/'));
}
let r = match_chunk(chunk, name);
if let Ok(Some(t)) = r {
// The last chunk must exhaust the name.
if t.is_empty() || !pattern.is_empty() {
name = t;
continue;
}
}
r?;
if star {
// Look for a match skipping i+1 bytes; cannot skip /.
let mut i = 0;
while i < name.len() && name[i] != b'/' {
if let Some(t) = match_chunk(chunk, &name[i + 1..])? {
if !(pattern.is_empty() && !t.is_empty()) {
name = t;
continue 'pattern;
}
}
i += 1;
}
}
// Before failing, check the rest of the pattern is well-formed.
while !pattern.is_empty() {
let (_, chunk, rest) = scan_chunk(pattern);
pattern = rest;
match_chunk(chunk, b"")?;
}
return Ok(false);
}
Ok(name.is_empty())
}
fn scan_chunk(mut pattern: &[u8]) -> (bool, &[u8], &[u8]) {
let mut star = false;
while pattern.first() == Some(&b'*') {
pattern = &pattern[1..];
star = true;
}
let mut in_range = false;
let mut i = 0;
while i < pattern.len() {
match pattern[i] {
b'\\' if i + 1 < pattern.len() => i += 1,
b'[' => in_range = true,
b']' => in_range = false,
b'*' if !in_range => break,
_ => {}
}
i += 1;
}
(star, &pattern[..i], &pattern[i..])
}
/// Matches one chunk against the start of s, returning the rest of s.
fn match_chunk<'a>(mut chunk: &[u8], mut s: &'a [u8]) -> Result<Option<&'a [u8]>, BadPattern> {
// After a failure, keep parsing chunk to validate it, but stop reading s.
let mut failed = false;
while !chunk.is_empty() {
if !failed && s.is_empty() {
failed = true;
}
match chunk[0] {
b'[' => {
let mut r = 0;
if !failed {
let (c, n) = decode_rune(s);
r = c;
s = &s[n..];
}
chunk = &chunk[1..];
let negated = chunk.first() == Some(&b'^');
if negated {
chunk = &chunk[1..];
}
let (mut matched, mut nrange) = (false, 0);
loop {
if chunk.first() == Some(&b']') && nrange > 0 {
chunk = &chunk[1..];
break;
}
let (lo, rest) = get_esc(chunk)?;
chunk = rest;
let mut hi = lo;
if chunk[0] == b'-' {
(hi, chunk) = get_esc(&chunk[1..])?;
}
if lo <= r && r <= hi {
matched = true;
}
nrange += 1;
}
if matched == negated {
failed = true;
}
}
b'?' => {
if !failed {
if s[0] == b'/' {
failed = true;
}
s = &s[decode_rune(s).1..];
}
chunk = &chunk[1..];
}
c => {
let c = if c == b'\\' {
chunk = &chunk[1..];
*chunk.first().ok_or(BadPattern)?
} else {
c
};
if !failed {
if c != s[0] {
failed = true;
}
s = &s[1..];
}
chunk = &chunk[1..];
}
}
}
Ok((!failed).then_some(s))
}
fn get_esc(mut chunk: &[u8]) -> Result<(u32, &[u8]), BadPattern> {
if chunk.is_empty() || chunk[0] == b'-' || chunk[0] == b']' {
return Err(BadPattern);
}
if chunk[0] == b'\\' {
chunk = &chunk[1..];
if chunk.is_empty() {
return Err(BadPattern);
}
}
let (r, n) = decode_rune(chunk);
if (r == 0xFFFD && n == 1) || chunk.len() == n {
return Err(BadPattern);
}
Ok((r, &chunk[n..]))
}
/// Lexically cleans a path like Go's filepath.Clean.
@@ -703,5 +871,15 @@ mod path_tests {
assert!(glob_match(b"*.log", b"x.log"));
assert!(!glob_match(b"*.log", b"d/x.log"));
assert!(glob_match(b"/tmp/*/x", b"/tmp/a/x"));
assert!(!glob_match(b"[", b"["));
assert!(glob_match(b"a[^b-d]?", "ax\u{e9}".as_bytes()));
assert!(!glob_match(b"a[^b-d]c", b"abc"));
assert!(glob_match(b"\\*x", b"*x"));
assert!(glob_match(b"*.dat", b"f1.dat"));
assert!(!glob_match(b"*.dat", b"f1.dax"));
assert!(glob_match(b"a*b*c", b"axxbyyc"));
assert!(glob_match(b"*/x", b"a/x") && !glob_match(b"*/x", b"a/b/x"));
assert!(glob_match(b"*", b"abc") && !glob_match(b"*", b"a/c"));
assert!(!glob_match(b"*.dat", b"x.dat/y.da") && !glob_match(b"*.dat", b"d/x.dat"));
}
}