rust: faster filter path/glob evaluation; port filepath.Match; interleaved bench runners
This commit is contained in:
Executable
+45
@@ -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}× |")
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user