diff --git a/rust/bench/micro.py b/rust/bench/micro.py new file mode 100755 index 0000000..27f4749 --- /dev/null +++ b/rust/bench/micro.py @@ -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}× |") diff --git a/rust/src/filter/apply.rs b/rust/src/filter/apply.rs index 6b9b4d5..6fa4200 100644 --- a/rust/src/filter/apply.rs +++ b/rust/src/filter/apply.rs @@ -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, m: SizeMode) -> Option, m: SizeMode) -> Option 0 && q.matches(&mut c) { matched[i] = true; diff --git a/rust/src/filter/eval.rs b/rust/src/filter/eval.rs index 5f112c1..1ce677c 100644 --- a/rust/src/filter/eval.rs +++ b/rust/src/filter/eval.rs @@ -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, - // 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>, + // 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>, +} + +/// 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::Name => { + self.name.clear(); + push_lower(&mut self.name, &n.name); + self.name.as_str().into() + } Field::Path => { - if self.path.is_none() { - self.path = Some(self.lower_path()); + if !self.has_path { + self.fill_path(); } - self.path.as_deref().unwrap().into() + 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(); - } - 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()); + /// 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('/'); } + 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()); } } diff --git a/rust/src/platform.rs b/rust/src/platform.rs index 9009672..d52c8de 100644 --- a/rust/src/platform.rs +++ b/rust/src/platform.rs @@ -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 { + '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, 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")); } }