from __future__ import annotations
import argparse
import json
import resource
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Sequence
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = Path(__file__).resolve().parent / "out"
sys.path.insert(0, str(ROOT))
HEIGHT, WIDTH = 720, 1280
N_TIME_BINS_VOXEL = 5
N_TIME_BINS_FRAME = 10
OPS = ("voxel_grid", "event_frame", "time_surface")
TIME_SURFACE_SLICES = 20 BACKENDS = ("tonic", "evlib_cpu", "evlib_gpu_uvm")
LABEL = {
"tonic": "tonic (NumPy)",
"evlib_cpu": "evlib Polars (CPU)",
"evlib_gpu_uvm": "evlib Polars (GPU / cudf UVM)",
}
def ru_maxrss_bytes(v: int) -> int:
return int(v) if sys.platform == "darwin" else int(v) * 1024
def _uvm_engine():
import polars as pl
import rmm
mr = rmm.mr.PrefetchResourceAdaptor(
rmm.mr.PoolMemoryResource(rmm.mr.ManagedMemoryResource())
)
return pl.GPUEngine(memory_resource=mr)
def _load_events(raw: Path, n_events: int):
import evlib
return evlib.load_events(str(raw)).limit(n_events).collect()
def _tonic_array(df):
x = df["x"].to_numpy().astype(np.int16)
y = df["y"].to_numpy().astype(np.int16)
t = df["t"].dt.total_microseconds().to_numpy().astype(np.int64)
pol = df["polarity"].to_numpy()
p = ((pol > 0).astype(np.int8)) dtype = np.dtype([("x", np.int16), ("y", np.int16), ("t", np.int64), ("p", np.int8)])
arr = np.empty(len(x), dtype=dtype)
arr["x"], arr["y"], arr["t"], arr["p"] = x, y, t, p
return arr
def _child(args: argparse.Namespace) -> None:
op, backend = args.child, args.backend
df = _load_events(Path(args.raw), args.n_events)
n = df.height
sensor = (WIDTH, HEIGHT, 2)
if backend == "tonic":
import tonic.transforms as T
arr = _tonic_array(df)
else:
import evlib.representations as evr
engine = _uvm_engine() if backend == "evlib_gpu_uvm" else "auto"
lf = df.lazy()
dt = tau = 0.0
if op == "time_surface":
t_us = df["t"].dt.total_microseconds().to_numpy()
span = float(t_us[-1] - t_us[0])
dt = span / TIME_SURFACE_SLICES
tau = 2.0 * dt
start = time.perf_counter()
if backend == "tonic":
if op == "voxel_grid":
out = T.ToVoxelGrid(sensor_size=sensor, n_time_bins=N_TIME_BINS_VOXEL)(arr)
elif op == "event_frame":
out = T.ToFrame(sensor_size=sensor, n_time_bins=N_TIME_BINS_FRAME)(arr)
elif op == "time_surface":
out = T.ToTimesurface(sensor_size=sensor, dt=dt, tau=tau)(arr)
else:
raise ValueError(op)
sig = float(np.asarray(out).sum())
else:
if op == "voxel_grid":
res = evr.create_voxel_grid(lf, HEIGHT, WIDTH, N_TIME_BINS_VOXEL, engine=engine)
elif op == "event_frame":
res = evr.create_event_frame(lf, HEIGHT, WIDTH, N_TIME_BINS_FRAME, engine=engine)
elif op == "time_surface":
res = evr.create_time_surface(lf, HEIGHT, WIDTH, dt=dt, tau=tau, engine=engine)
else:
raise ValueError(op)
sig = float(res.height)
wall = time.perf_counter() - start
peak = ru_maxrss_bytes(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
print(json.dumps({"wall_s": wall, "peak_rss_bytes": peak, "n_events": n, "sig": sig}))
def _run(op: str, backend: str, raw: Path, n_events: int, timeout: float) -> Dict:
proc = subprocess.run(
[
sys.executable, "-m", "benchmarks.bench_tonic",
"--child", op, "--backend", backend, "--raw", str(raw),
"--n-events", str(n_events),
],
cwd=str(ROOT), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout,
)
if proc.returncode != 0:
raise RuntimeError(f"{op}/{backend} failed (rc={proc.returncode})\n{proc.stdout}\n{proc.stderr}")
for line in proc.stdout.splitlines():
line = line.strip()
if line.startswith("{") and "peak_rss_bytes" in line:
return json.loads(line)
raise RuntimeError(f"no JSON from {op}/{backend}:\n{proc.stdout}\n{proc.stderr}")
def plot(results: Dict[str, Dict[str, Dict]], ops: Sequence[str], out_png: Path) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "Tahoma"
colour = {"tonic": "#d86b3b", "evlib_cpu": "#3b7dd8", "evlib_gpu_uvm": "#2a9d5c"}
backends = [b for b in BACKENDS if any(b in results[op] for op in ops)]
fig, ax = plt.subplots(figsize=(8, 1.4 + 1.1 * len(ops)))
n = len(backends)
h = 0.8 / n
y0 = np.arange(len(ops))
for j, b in enumerate(backends):
vals = [results[op].get(b, {}).get("wall_s", 0.0) for op in ops]
ypos = y0 + (j - (n - 1) / 2) * h
ax.barh(ypos, vals, height=h, color=colour.get(b, "#888"), label=LABEL[b], zorder=3)
for yp, v in zip(ypos, vals):
if v:
ax.text(v, yp, f" {v:.2f}s", va="center", ha="left", fontsize=8)
ax.set_yticks(y0)
ax.set_yticklabels(ops)
ax.invert_yaxis()
ax.set_xlabel("wall-clock time (s), lower is better")
ax.set_title("evlib vs tonic representations (20M events)", loc="left", fontsize=12, fontweight="bold")
ax.legend(fontsize=8, loc="lower right")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.set_facecolor("#f7f7f7")
ax.grid(axis="x", color="white", linewidth=1.2, zorder=0)
ax.set_axisbelow(True)
fig.tight_layout()
fig.savefig(out_png, dpi=300)
plt.close(fig)
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--raw", type=Path)
parser.add_argument("--n-events", type=int, default=30_000_000)
parser.add_argument("--backends", nargs="+", default=list(BACKENDS), choices=list(BACKENDS))
parser.add_argument("--ops", nargs="+", default=list(OPS), choices=list(OPS))
parser.add_argument("--timeout", type=float, default=1800.0)
parser.add_argument("--out-prefix", default="tonic_bench")
parser.add_argument("--replot", action="store_true", help="re-render plot from saved results JSON")
parser.add_argument("--child", default=None, help=argparse.SUPPRESS)
parser.add_argument("--backend", default=None, help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if args.child is not None:
_child(args)
return 0
OUT_DIR.mkdir(parents=True, exist_ok=True)
results_json = OUT_DIR / f"{args.out_prefix}_results.json"
if args.replot:
results = json.loads(results_json.read_text())
plot(results, [op for op in args.ops if op in results], OUT_DIR / f"{args.out_prefix}_time.png")
print(f"Wrote {OUT_DIR / f'{args.out_prefix}_time.png'}")
return 0
if args.raw is None:
parser.error("--raw is required")
results: Dict[str, Dict[str, Dict]] = {}
for op in args.ops:
results[op] = {}
print(f"\n=== {op} ({args.n_events:,} events) ===")
for backend in args.backends:
r = _run(op, backend, args.raw, args.n_events, args.timeout)
results[op][backend] = r
print(f" {LABEL[backend]:30s} {r['wall_s']:8.2f}s peak {r['peak_rss_bytes']/1024**3:6.2f} GB")
md = OUT_DIR / f"{args.out_prefix}.md"
lines = [
"# evlib vs tonic representation benchmark",
"",
f"Single event stream, {args.n_events:,} events, eTram (1280x720). Identical events to both.",
"Wall-clock and peak RSS per (op, backend) in isolated subprocesses (single pass).",
"",
]
for op in args.ops:
lines += [f"## {op}", "", "| backend | time (s) | peak RSS (GB) | events/s |", "| --- | --- | --- | --- |"]
for backend in args.backends:
r = results[op][backend]
eps = r["n_events"] / r["wall_s"]
lines.append(f"| {LABEL[backend]} | {r['wall_s']:.2f} | {r['peak_rss_bytes']/1024**3:.2f} | {eps/1e6:.1f}M |")
if "tonic" in results[op]:
tw = results[op]["tonic"]["wall_s"]
for backend in args.backends:
if backend == "tonic":
continue
bw = results[op][backend]["wall_s"]
rel = f"{tw/bw:.2f}x faster" if bw <= tw else f"{bw/tw:.2f}x slower"
lines.append(f"")
lines.append(f"{LABEL[backend]} is {rel} than tonic for {op} ({bw:.2f}s vs {tw:.2f}s).")
lines.append("")
results_json.write_text(json.dumps(results, indent=2))
md.write_text("\n".join(lines) + "\n")
try:
plot(results, args.ops, OUT_DIR / f"{args.out_prefix}_time.png")
print(f"Wrote {OUT_DIR / f'{args.out_prefix}_time.png'}")
except ImportError as exc:
print(f"Skipped plot (matplotlib unavailable: {exc}); run --replot where it is installed.")
print(f"Wrote {md}")
print("\n" + md.read_text())
return 0
if __name__ == "__main__":
raise SystemExit(main())