Skip to content

Commit 33ed8ed

Browse files
sidmohan0claude
andcommitted
Add v4 vs v5 benchmark comparison suite
bench/compare.py orchestrates side-by-side benchmarking: clones the old datafog-python repo, sets up isolated venvs, runs benchmarks in parallel, and prints a comparison table. bench/runner.py is the shared benchmark runner that auto-detects which package version is installed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 171401d commit 33ed8ed

4 files changed

Lines changed: 663 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ Thumbs.db
3232
.pytest_cache/
3333
htmlcov/
3434
*.prof
35+
bench/.workspaces/

bench/compare.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compare datafog v5 (Rust) vs v4 (Python) side-by-side.
4+
5+
Sets up two isolated workspaces, installs each package, runs benchmarks
6+
in parallel, and prints a comparison table.
7+
8+
Usage:
9+
python bench/compare.py # full setup + benchmark
10+
python bench/compare.py --no-setup # skip setup, reuse existing workspaces
11+
python bench/compare.py --clean # wipe workspaces and start fresh
12+
"""
13+
14+
import argparse
15+
import json
16+
import os
17+
import shutil
18+
import subprocess
19+
import sys
20+
from concurrent.futures import ThreadPoolExecutor, as_completed
21+
from pathlib import Path
22+
23+
REPO_ROOT = Path(__file__).resolve().parent.parent
24+
WORKSPACE_DIR = REPO_ROOT / "bench" / ".workspaces"
25+
OLD_DIR = WORKSPACE_DIR / "old"
26+
NEW_DIR = WORKSPACE_DIR / "new"
27+
RUNNER = REPO_ROOT / "bench" / "runner.py"
28+
29+
OLD_REPO_URL = "https://github.com/DataFog/datafog-python.git"
30+
31+
32+
def run(cmd, cwd=None, check=True):
33+
"""Run a shell command, printing it for visibility."""
34+
print(f" $ {cmd}")
35+
result = subprocess.run(
36+
cmd, shell=True, cwd=cwd,
37+
capture_output=True, text=True,
38+
)
39+
if check and result.returncode != 0:
40+
print(f" FAILED (exit {result.returncode})")
41+
if result.stderr:
42+
for line in result.stderr.strip().splitlines()[-3:]:
43+
print(f" {line}")
44+
sys.exit(1)
45+
return result
46+
47+
48+
def find_python(max_version=(3, 13)):
49+
"""Find a Python interpreter that satisfies max_version constraint.
50+
51+
The old datafog package requires Python <3.13. We need to find
52+
a compatible interpreter. Returns the path or None.
53+
"""
54+
# Check common locations
55+
candidates = [
56+
"python3.12", "python3.11", "python3.10",
57+
"/opt/homebrew/bin/python3.12",
58+
"/opt/homebrew/bin/python3.11",
59+
"/opt/homebrew/bin/python3.10",
60+
"/usr/local/bin/python3.12",
61+
"/usr/local/bin/python3.11",
62+
"/usr/local/bin/python3.10",
63+
]
64+
for candidate in candidates:
65+
try:
66+
result = subprocess.run(
67+
[candidate, "--version"],
68+
capture_output=True, text=True,
69+
)
70+
if result.returncode == 0:
71+
version_str = result.stdout.strip().split()[-1]
72+
parts = tuple(int(x) for x in version_str.split(".")[:2])
73+
if parts < max_version:
74+
return candidate
75+
except FileNotFoundError:
76+
continue
77+
78+
# Fall back to system python3 if it happens to be compatible
79+
try:
80+
result = subprocess.run(
81+
["python3", "--version"], capture_output=True, text=True
82+
)
83+
version_str = result.stdout.strip().split()[-1]
84+
parts = tuple(int(x) for x in version_str.split(".")[:2])
85+
if parts < max_version:
86+
return "python3"
87+
except FileNotFoundError:
88+
pass
89+
90+
return None
91+
92+
93+
def setup_old():
94+
"""Clone the old datafog-python repo and install it in a venv."""
95+
print("\n--- Setting up OLD workspace (v4 Python) ---\n")
96+
97+
# The old package requires Python <3.13
98+
old_python = find_python(max_version=(3, 13))
99+
if old_python is None:
100+
print(" ERROR: datafog v4 requires Python <3.13 but none found.")
101+
print(" Install Python 3.11 or 3.12 (e.g. `brew install python@3.12`)")
102+
sys.exit(1)
103+
104+
result = subprocess.run([old_python, "--version"], capture_output=True, text=True)
105+
print(f" Using {old_python} ({result.stdout.strip()})")
106+
107+
if not (OLD_DIR / ".git").exists():
108+
OLD_DIR.mkdir(parents=True, exist_ok=True)
109+
run(f"git clone --depth 1 {OLD_REPO_URL} {OLD_DIR}")
110+
else:
111+
print(f" (reusing existing clone at {OLD_DIR})")
112+
113+
venv = OLD_DIR / ".venv"
114+
if not venv.exists():
115+
run(f"{old_python} -m venv {venv}")
116+
117+
pip = venv / "bin" / "pip"
118+
run(f"{pip} install -q --upgrade pip")
119+
run(f"{pip} install -q {OLD_DIR}")
120+
121+
shutil.copy2(RUNNER, OLD_DIR / "runner.py")
122+
print(" done.\n")
123+
124+
125+
def setup_new():
126+
"""Install the new package from the current repo into a venv."""
127+
print("\n--- Setting up NEW workspace (v5 Rust) ---\n")
128+
129+
NEW_DIR.mkdir(parents=True, exist_ok=True)
130+
venv = NEW_DIR / ".venv"
131+
if not venv.exists():
132+
run(f"python3 -m venv {venv}")
133+
134+
pip = venv / "bin" / "pip"
135+
run(f"{pip} install -q --upgrade pip")
136+
run(f"{pip} install -q {REPO_ROOT}")
137+
138+
shutil.copy2(RUNNER, NEW_DIR / "runner.py")
139+
print(" done.\n")
140+
141+
142+
def run_benchmark(workspace, label):
143+
"""Run runner.py in the workspace's venv and return parsed JSON."""
144+
python = workspace / ".venv" / "bin" / "python"
145+
runner = workspace / "runner.py"
146+
result = subprocess.run(
147+
[str(python), str(runner)],
148+
capture_output=True, text=True, cwd=str(workspace),
149+
)
150+
if result.returncode != 0:
151+
print(f"\n {label} benchmark failed:")
152+
if result.stderr:
153+
print(f" {result.stderr.strip()}")
154+
if result.stdout:
155+
print(f" {result.stdout.strip()}")
156+
return None
157+
return json.loads(result.stdout)
158+
159+
160+
def print_table(old, new):
161+
"""Print a side-by-side comparison table."""
162+
tests = [
163+
("detect_single", "detect single (228 B)"),
164+
("detect_batch_10", "detect batch x10 (398 B)"),
165+
("detect_large", "detect large (22.8 KB)"),
166+
("anonymize_redact", "anonymize redact (167 B)"),
167+
("anonymize_hash", "anonymize hash (167 B)"),
168+
]
169+
170+
col_test = 28
171+
col_val = 16
172+
col_speedup = 10
173+
174+
sep = f" {'─' * col_test}{'─' * col_val}{'─' * col_val}{'─' * col_speedup}"
175+
176+
print(f"\n{'='*72}")
177+
print(f" datafog v4 (Python) vs v5 (Rust) — median latency, 200 rounds")
178+
print(f"{'='*72}\n")
179+
180+
hdr = (
181+
f" {'Test':<{col_test}}│"
182+
f"{'v4 Python (μs)':^{col_val}}│"
183+
f"{'v5 Rust (μs)':^{col_val}}│"
184+
f"{'Speedup':^{col_speedup}}"
185+
)
186+
print(hdr)
187+
print(sep)
188+
189+
for key, label in tests:
190+
old_val = old.get(key, {}).get("median_us", 0)
191+
new_val = new.get(key, {}).get("median_us", 0)
192+
speedup = old_val / new_val if new_val > 0 else float("inf")
193+
194+
old_str = f"{old_val:,.1f}"
195+
new_str = f"{new_val:,.1f}"
196+
speedup_str = f"{speedup:,.1f}x"
197+
198+
print(
199+
f" {label:<{col_test}}│"
200+
f"{old_str:^{col_val}}│"
201+
f"{new_str:^{col_val}}│"
202+
f"{speedup_str:^{col_speedup}}"
203+
)
204+
205+
print(sep)
206+
207+
# Entity coverage
208+
old_ents = old.get("entities_found", [])
209+
new_ents = new.get("entities_found", [])
210+
print(f"\n v4 entities: {old_ents}")
211+
print(f" v5 entities: {new_ents}")
212+
print(f" v4 count: {old.get('entity_count', '?')} v5 count: {new.get('entity_count', '?')}")
213+
print()
214+
215+
216+
def main():
217+
parser = argparse.ArgumentParser(description="Compare datafog v4 vs v5 performance")
218+
parser.add_argument("--no-setup", action="store_true", help="Skip setup, reuse existing workspaces")
219+
parser.add_argument("--clean", action="store_true", help="Wipe workspaces and start fresh")
220+
args = parser.parse_args()
221+
222+
if args.clean:
223+
print(f"Cleaning {WORKSPACE_DIR} ...")
224+
shutil.rmtree(WORKSPACE_DIR, ignore_errors=True)
225+
226+
if not args.no_setup:
227+
# Run setup in parallel — old clone + new build happen concurrently
228+
with ThreadPoolExecutor(max_workers=2) as pool:
229+
futures = [pool.submit(setup_old), pool.submit(setup_new)]
230+
for f in as_completed(futures):
231+
f.result()
232+
233+
for label, d in [("old", OLD_DIR), ("new", NEW_DIR)]:
234+
if not (d / ".venv" / "bin" / "python").exists():
235+
print(f"Error: {label} workspace not set up. Run without --no-setup.", file=sys.stderr)
236+
sys.exit(1)
237+
238+
# Run benchmarks in parallel
239+
print("\n--- Running benchmarks ---\n")
240+
with ThreadPoolExecutor(max_workers=2) as pool:
241+
f_old = pool.submit(run_benchmark, OLD_DIR, "v4")
242+
f_new = pool.submit(run_benchmark, NEW_DIR, "v5")
243+
old_results = f_old.result()
244+
new_results = f_new.result()
245+
246+
if old_results is None or new_results is None:
247+
print("Benchmark failed.", file=sys.stderr)
248+
sys.exit(1)
249+
250+
print_table(old_results, new_results)
251+
252+
253+
if __name__ == "__main__":
254+
main()

0 commit comments

Comments
 (0)