Skip to content

Commit 2e503e4

Browse files
Merge pull request #12 from SaridakisStamatisChristos/codex/create-benchmark-runner-cli-script
Refine benchmark tooling and add CI smoke run
2 parents 7acfd8d + 88fc475 commit 2e503e4

File tree

10 files changed

+669
-2
lines changed

10 files changed

+669
-2
lines changed

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,30 @@ jobs:
2222
uses: codecov/codecov-action@v5
2323
with:
2424
token: ${{ secrets.CODECOV_TOKEN }}
25+
bench:
26+
runs-on: ubuntu-latest
27+
needs: test
28+
steps:
29+
- uses: actions/checkout@v4
30+
- uses: actions/setup-python@v5
31+
with:
32+
python-version: "3.11"
33+
- name: Install bench extras
34+
run: |
35+
python -m pip install --upgrade pip
36+
python -m pip install -e './kll_sketch[bench]'
37+
- name: Run smoke benchmark
38+
working-directory: ${{ github.workspace }}
39+
run: |
40+
python benchmarks/bench_kll.py \
41+
--outdir bench_out \
42+
--Ns 1e5 \
43+
--capacities 200 \
44+
--distributions normal \
45+
--qs 0.25 0.5 0.75 \
46+
--shards 4
47+
- name: Upload benchmark artifacts
48+
uses: actions/upload-artifact@v4
49+
with:
50+
name: bench-out
51+
path: bench_out

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
bench_out/

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,38 @@ python -m pytest -q
101101

102102
---
103103

104+
## 📈 Benchmarks
105+
106+
Get the optional tooling with extras:
107+
108+
```bash
109+
python -m pip install -e .[bench,test]
110+
```
111+
112+
Run the full synthetic sweep (matching the defaults in the docs):
113+
114+
```bash
115+
python benchmarks/bench_kll.py \
116+
--module kll_sketch --class KLLSketch \
117+
--outdir bench_out \
118+
--Ns 1e5 1e6 \
119+
--capacities 200 400 800 \
120+
--distributions uniform normal exponential pareto bimodal \
121+
--qs 0.01 0.05 0.1 0.25 0.5 0.75 0.9 0.95 0.99 \
122+
--shards 8
123+
```
124+
125+
Artifacts land in `bench_out/` with the following schema:
126+
127+
- `accuracy.csv` — distribution, `N`, capacity, mode (`single`/`merged`), quantile, estimate, exact, and absolute value error.
128+
- `update_throughput.csv` — distribution, `N`, capacity, update wall time (seconds), and computed inserts/sec.
129+
- `query_latency.csv` — distribution, `N`, capacity, quantile, and per-query latency in microseconds.
130+
- `merge.csv` — distribution, `N`, capacity, shard count, and merge wall time (seconds).
131+
132+
Visualise the outputs via `benchmarks/bench_plots.ipynb`, and read [`docs/benchmarks.md`](docs/benchmarks.md) for a narrated walkthrough.
133+
134+
---
135+
104136
## 🗺️ Roadmap
105137

106138
* Optional NumPy/C hot paths for sort/merge.

benchmarks/bench_kll.py

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
#!/usr/bin/env python3
2+
"""Benchmark runner for the local kll_sketch implementation."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import importlib
8+
import hashlib
9+
import math
10+
import time
11+
from pathlib import Path
12+
from typing import Callable, Dict, Iterable, List, Sequence
13+
14+
import numpy as np
15+
import pandas as pd
16+
17+
18+
def _parse_args() -> argparse.Namespace:
19+
parser = argparse.ArgumentParser(description=__doc__)
20+
parser.add_argument("--module", default="kll_sketch", help="Module that exports the sketch class")
21+
parser.add_argument("--class", dest="cls", default="KLLSketch", help="Sketch class name inside the module")
22+
parser.add_argument("--outdir", default="bench_out", help="Directory for benchmark CSV outputs")
23+
parser.add_argument("--seed", type=int, default=42, help="Base RNG seed for reproducibility")
24+
parser.add_argument("--Ns", nargs="+", default=["1e5", "1e6"], help="Population sizes to benchmark")
25+
parser.add_argument(
26+
"--capacities", nargs="+", default=["200", "400", "800"], help="Sketch capacities to benchmark"
27+
)
28+
parser.add_argument(
29+
"--distributions",
30+
nargs="+",
31+
default=["uniform", "normal", "exponential", "pareto", "bimodal"],
32+
help="Synthetic data distributions to sample",
33+
)
34+
parser.add_argument(
35+
"--qs",
36+
nargs="+",
37+
default=["0.01", "0.05", "0.1", "0.25", "0.5", "0.75", "0.9", "0.95", "0.99"],
38+
help="Quantiles to evaluate",
39+
)
40+
parser.add_argument("--shards", type=int, default=8, help="Number of shards for the merge benchmark")
41+
return parser.parse_args()
42+
43+
44+
def _to_int_list(values: Iterable[str]) -> List[int]:
45+
return [int(float(v)) for v in values]
46+
47+
48+
def _to_float_list(values: Iterable[str]) -> List[float]:
49+
return [float(v) for v in values]
50+
51+
52+
def _hash_seed(seed: int, *parts: object) -> int:
53+
material = "::".join(str(p) for p in (seed,) + parts)
54+
digest = hashlib.sha256(material.encode("utf-8")).hexdigest()
55+
return int(digest[:16], 16)
56+
57+
58+
def _uniform(rng: np.random.Generator, size: int) -> np.ndarray:
59+
return rng.uniform(0.0, 1.0, size)
60+
61+
62+
def _normal(rng: np.random.Generator, size: int) -> np.ndarray:
63+
return rng.normal(0.0, 1.0, size)
64+
65+
66+
def _exponential(rng: np.random.Generator, size: int) -> np.ndarray:
67+
return rng.exponential(scale=1.0, size=size)
68+
69+
70+
def _pareto(rng: np.random.Generator, size: int) -> np.ndarray:
71+
return rng.pareto(a=1.5, size=size)
72+
73+
74+
def _bimodal(rng: np.random.Generator, size: int) -> np.ndarray:
75+
left = size // 2
76+
right = size - left
77+
first = rng.normal(-2.0, 1.0, left)
78+
second = rng.normal(2.0, 0.5, right)
79+
data = np.concatenate([first, second]) if size else np.empty(0, dtype=float)
80+
rng.shuffle(data)
81+
return data
82+
83+
84+
DATA_GENERATORS: Dict[str, Callable[[np.random.Generator, int], np.ndarray]] = {
85+
"uniform": _uniform,
86+
"normal": _normal,
87+
"exponential": _exponential,
88+
"pareto": _pareto,
89+
"bimodal": _bimodal,
90+
}
91+
92+
93+
def _validate_distributions(names: Sequence[str]) -> None:
94+
unknown = sorted(set(names) - DATA_GENERATORS.keys())
95+
if unknown:
96+
raise ValueError(f"Unknown distributions requested: {', '.join(unknown)}")
97+
98+
99+
def _instantiate_sketch(sketch_cls, capacity: int, seed: int):
100+
try:
101+
return sketch_cls(capacity=capacity, rng_seed=seed)
102+
except TypeError:
103+
# Older signatures might use positional arguments only.
104+
return sketch_cls(capacity)
105+
106+
107+
def main() -> None:
108+
args = _parse_args()
109+
110+
Ns = _to_int_list(args.Ns)
111+
capacities = _to_int_list(args.capacities)
112+
qs = _to_float_list(args.qs)
113+
_validate_distributions(args.distributions)
114+
115+
module = importlib.import_module(args.module)
116+
cls_name = args.cls
117+
if not hasattr(module, cls_name):
118+
fallback_names = []
119+
if not cls_name.endswith("Sketch"):
120+
fallback_names.append(f"{cls_name}Sketch")
121+
else:
122+
base = cls_name[: -len("Sketch")]
123+
if base:
124+
fallback_names.append(base)
125+
fallback_names.extend(["KLLSketch", "KLL"])
126+
for candidate in fallback_names:
127+
if hasattr(module, candidate):
128+
cls_name = candidate
129+
break
130+
else:
131+
available = ", ".join(sorted(attr for attr in dir(module) if not attr.startswith("_")))
132+
raise AttributeError(
133+
f"{module.__name__!r} does not define {args.cls!r}. Available attributes: {available}"
134+
)
135+
136+
sketch_cls = getattr(module, cls_name)
137+
138+
outdir = Path(args.outdir)
139+
outdir.mkdir(parents=True, exist_ok=True)
140+
141+
accuracy_records: List[Dict[str, object]] = []
142+
throughput_records: List[Dict[str, object]] = []
143+
latency_records: List[Dict[str, object]] = []
144+
merge_records: List[Dict[str, object]] = []
145+
146+
for dist in args.distributions:
147+
for N in Ns:
148+
combo_seed = _hash_seed(args.seed, dist, N)
149+
data_rng = np.random.default_rng(combo_seed)
150+
generator = DATA_GENERATORS[dist]
151+
data = generator(data_rng, N).astype(float, copy=False)
152+
if data.size != N:
153+
data = np.resize(data, N)
154+
155+
exact_quantiles = np.quantile(data, qs, method="linear")
156+
exact_map = dict(zip(qs, exact_quantiles))
157+
158+
for capacity in capacities:
159+
sketch = _instantiate_sketch(sketch_cls, capacity, args.seed)
160+
start = time.perf_counter()
161+
for value in data:
162+
sketch.add(float(value))
163+
update_elapsed = time.perf_counter() - start
164+
updates_per_sec = (N / update_elapsed) if update_elapsed > 0 else math.inf
165+
166+
throughput_records.append(
167+
{
168+
"distribution": dist,
169+
"N": int(N),
170+
"capacity": int(capacity),
171+
"update_time_s": update_elapsed,
172+
"updates_per_sec": updates_per_sec,
173+
}
174+
)
175+
176+
for q in qs:
177+
q_float = float(q)
178+
q_start = time.perf_counter()
179+
approx = sketch.quantile(q_float)
180+
q_elapsed = time.perf_counter() - q_start
181+
latency_records.append(
182+
{
183+
"distribution": dist,
184+
"N": int(N),
185+
"capacity": int(capacity),
186+
"q": q_float,
187+
"latency_us": q_elapsed * 1e6,
188+
}
189+
)
190+
accuracy_records.append(
191+
{
192+
"distribution": dist,
193+
"N": int(N),
194+
"capacity": int(capacity),
195+
"mode": "single",
196+
"q": q_float,
197+
"estimate": approx,
198+
"exact": exact_map[q_float],
199+
"abs_error": abs(approx - exact_map[q_float]),
200+
}
201+
)
202+
203+
shard_arrays = np.array_split(data, args.shards)
204+
shard_sketches = []
205+
for shard_idx, shard in enumerate(shard_arrays):
206+
shard_sketch = _instantiate_sketch(
207+
sketch_cls, capacity, args.seed + shard_idx + 1
208+
)
209+
for value in shard:
210+
shard_sketch.add(float(value))
211+
shard_sketches.append(shard_sketch)
212+
213+
merge_target = _instantiate_sketch(sketch_cls, capacity, args.seed)
214+
merge_start = time.perf_counter()
215+
for shard_sketch in shard_sketches:
216+
merge_target.merge(shard_sketch)
217+
merge_elapsed = time.perf_counter() - merge_start
218+
219+
merge_records.append(
220+
{
221+
"distribution": dist,
222+
"N": int(N),
223+
"capacity": int(capacity),
224+
"shards": int(args.shards),
225+
"merge_time_s": merge_elapsed,
226+
}
227+
)
228+
229+
for q in qs:
230+
q_float = float(q)
231+
approx = merge_target.quantile(q_float)
232+
accuracy_records.append(
233+
{
234+
"distribution": dist,
235+
"N": int(N),
236+
"capacity": int(capacity),
237+
"mode": "merged",
238+
"q": q_float,
239+
"estimate": approx,
240+
"exact": exact_map[q_float],
241+
"abs_error": abs(approx - exact_map[q_float]),
242+
}
243+
)
244+
245+
accuracy_path = outdir / "accuracy.csv"
246+
throughput_path = outdir / "update_throughput.csv"
247+
latency_path = outdir / "query_latency.csv"
248+
merge_path = outdir / "merge.csv"
249+
250+
pd.DataFrame.from_records(accuracy_records).to_csv(accuracy_path, index=False)
251+
pd.DataFrame.from_records(throughput_records).to_csv(throughput_path, index=False)
252+
pd.DataFrame.from_records(latency_records).to_csv(latency_path, index=False)
253+
pd.DataFrame.from_records(merge_records).to_csv(merge_path, index=False)
254+
255+
print("Benchmark artifacts written to:")
256+
print(f" {accuracy_path}")
257+
print(f" {throughput_path}")
258+
print(f" {latency_path}")
259+
print(f" {merge_path}")
260+
261+
262+
if __name__ == "__main__":
263+
main()

0 commit comments

Comments
 (0)