Skip to main content

benchmark_storage/
benchmark_storage.rs

1use std::{
2    collections::BTreeSet,
3    env,
4    path::Path,
5    process::Command,
6    time::{Duration, Instant},
7};
8
9use weavatrix_git::Repository;
10
11fn main() -> Result<(), Box<dyn std::error::Error>> {
12    let args = env::args().skip(1).collect::<Vec<_>>();
13    let path = args.first().map_or(".", String::as_str);
14    let iterations = args
15        .get(1)
16        .map(|value| value.parse())
17        .transpose()?
18        .unwrap_or(50);
19    let repository = Repository::open(path)?;
20    let head = repository.resolve("HEAD")?;
21
22    let expected_objects = git_object_ids(path)?;
23    let actual_objects = repository
24        .bitmap_reachable(head)?
25        .ok_or("repository has no reachability bitmap")?
26        .into_iter()
27        .map(|id| id.to_string())
28        .collect::<BTreeSet<_>>();
29    if actual_objects != expected_objects {
30        return Err("bitmap reachability differs from git rev-list --objects".into());
31    }
32    let expected_paths = git_paths(path)?;
33    let actual_paths = repository
34        .index()?
35        .entries()
36        .iter()
37        .map(|entry| entry.path.clone())
38        .collect::<Vec<_>>();
39    if actual_paths != expected_paths {
40        return Err("index paths differ from git ls-files".into());
41    }
42    let expected_status = git_status(path)?;
43    let actual_status = repository.status()?;
44    if !expected_status.is_empty() || !actual_status.is_empty() {
45        return Err(format!(
46            "status benchmark requires a clean tracked worktree (git={} bytes, weavatrix={actual_status:?})",
47            expected_status.len()
48        )
49        .into());
50    }
51
52    println!("operation,engine,p50_ms,p95_ms,items");
53    row(
54        "reachability",
55        "weavatrix-git",
56        &measure(iterations, || {
57            repository
58                .bitmap_reachable(head)
59                .map(|value| value.map_or(0, |ids| ids.len()))
60        })?,
61        actual_objects.len(),
62    );
63    row(
64        "reachability",
65        "git.exe",
66        &measure(iterations, || git_object_ids(path).map(|ids| ids.len()))?,
67        expected_objects.len(),
68    );
69    row(
70        "index",
71        "weavatrix-git",
72        &measure(iterations, || {
73            repository.index().map(|index| index.entries().len())
74        })?,
75        actual_paths.len(),
76    );
77    row(
78        "index",
79        "git.exe",
80        &measure(iterations, || git_paths(path).map(|paths| paths.len()))?,
81        expected_paths.len(),
82    );
83    row(
84        "tracked-status",
85        "weavatrix-git",
86        &measure(iterations, || {
87            repository.status().map(|entries| entries.len())
88        })?,
89        0,
90    );
91    row(
92        "tracked-status",
93        "git.exe",
94        &measure(iterations, || git_status(path).map(|entries| entries.len()))?,
95        0,
96    );
97    row(
98        "cached-commit",
99        "weavatrix-git",
100        &measure(iterations, || repository.commit(head).map(|_| 1))?,
101        1,
102    );
103    row(
104        "cached-commit",
105        "git.exe",
106        &measure(iterations, || git_exists(path, &head.to_string()))?,
107        1,
108    );
109    Ok(())
110}
111
112fn git_object_ids(path: impl AsRef<Path>) -> Result<BTreeSet<String>, Box<dyn std::error::Error>> {
113    Ok(git(path, &["rev-list", "--objects", "HEAD"])?
114        .lines()
115        .filter_map(|line| line.split_ascii_whitespace().next())
116        .map(str::to_owned)
117        .collect())
118}
119
120fn git_paths(path: impl AsRef<Path>) -> Result<Vec<Vec<u8>>, Box<dyn std::error::Error>> {
121    Ok(git_bytes(path, &["ls-files", "-z"])?
122        .split(|byte| *byte == 0)
123        .filter(|path| !path.is_empty())
124        .map(<[u8]>::to_vec)
125        .collect())
126}
127
128fn git_status(path: impl AsRef<Path>) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
129    git_bytes(path, &["status", "--porcelain=v1", "-uno"])
130}
131
132fn git_exists(path: impl AsRef<Path>, id: &str) -> Result<usize, Box<dyn std::error::Error>> {
133    let _ = git_bytes(path, &["cat-file", "-e", id])?;
134    Ok(1)
135}
136
137fn git(path: impl AsRef<Path>, args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
138    Ok(String::from_utf8(git_bytes(path, args)?)?)
139}
140
141fn git_bytes(path: impl AsRef<Path>, args: &[&str]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
142    let output = Command::new("git")
143        .arg("-C")
144        .arg(path.as_ref())
145        .args(args)
146        .output()?;
147    if !output.status.success() {
148        return Err(String::from_utf8_lossy(&output.stderr).into_owned().into());
149    }
150    Ok(output.stdout)
151}
152
153fn measure<T, E>(
154    iterations: usize,
155    mut operation: impl FnMut() -> Result<T, E>,
156) -> Result<Vec<Duration>, E> {
157    for _ in 0..3 {
158        let _ = operation()?;
159    }
160    let mut values = Vec::with_capacity(iterations);
161    for _ in 0..iterations {
162        let start = Instant::now();
163        let _ = operation()?;
164        values.push(start.elapsed());
165    }
166    values.sort_unstable();
167    Ok(values)
168}
169
170fn row(operation: &str, engine: &str, values: &[Duration], items: usize) {
171    let p50 = values[values.len() / 2].as_secs_f64() * 1_000.0;
172    let p95 = values[(values.len() * 95 / 100).min(values.len() - 1)].as_secs_f64() * 1_000.0;
173    println!("{operation},{engine},{p50:.3},{p95:.3},{items}");
174}