Repository navigation
Expand file tree
/
Copy pathbenchmark.py
More file actions
347 lines (292 loc) · 13.7 KB
/
Copy pathbenchmark.py
File metadata and controls
347 lines (292 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
"""
AI agent memory latency: local SQLite, local ChromaDB, and the MemorySync API.
Every number this script prints is measured on the machine that runs it. A
backend that cannot run (ChromaDB not installed, no network, a refused key) is
reported as not run. Nothing is ever substituted for a missing measurement.
The three backends do different work, so read the numbers as a shape, not a
race:
* SQLite is an in-process keyword filter on an indexed column: no network, no
ranking by meaning. Microseconds.
* ChromaDB runs in-process too, but embeds the query text locally before a
vector search. Tens to hundreds of milliseconds, depending on the CPU.
* MemorySync is a remote API: hybrid keyword plus vector ranking, per-user
scoping and persistence that survives the machine. Two numbers are reported:
the server's own query time (`latency_ms` in every response) and the full
round trip from this machine, which is dominated by distance to the API.
Usage:
pip install -r requirements.txt
python benchmark.py # all three backends
python benchmark.py --iterations 25
python benchmark.py --skip-remote # local backends only (used in CI)
The MemorySync run uses MEMORYSYNC_API_KEY if it is set, and otherwise creates
a free evaluation key with one unauthenticated API call (no signup).
"""
from __future__ import annotations
import argparse
import http.client
import json
import os
import sqlite3
import statistics
import time
import urllib.parse
API = "https://api.memorysync.io"
SEED_FACTS = [
"The production database runs PostgreSQL 16 in us-east-1.",
"Deploys are gated on the contract test suite passing in CI.",
"The team decided against a hosted vector database because of per-namespace pricing.",
"Tenant isolation is enforced by the database, not by application filters.",
"Background compaction runs nightly at 02:00 UTC.",
]
QUERIES = [
"what database do we use",
"why did we reject the hosted vector database",
"how is tenant isolation enforced",
"when does compaction run",
]
def percentile(values: list[float], p: float) -> float:
"""Nearest-rank percentile. Interpolation flatters small samples."""
ordered = sorted(values)
rank = max(1, min(len(ordered), int(round(p / 100.0 * len(ordered) + 0.5))))
return ordered[rank - 1]
def summary(values: list[float]) -> dict:
return {
"samples": len(values),
"p50_ms": round(statistics.median(values), 3),
"p95_ms": round(percentile(values, 95), 3),
"p99_ms": round(percentile(values, 99), 3),
}
# ---------------------------------------------------------------------------
# Local SQLite
# ---------------------------------------------------------------------------
def benchmark_sqlite(iterations: int) -> dict:
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE memories (id TEXT PRIMARY KEY, tenant_id TEXT, content TEXT, created_at REAL)")
cur.execute("CREATE INDEX idx_tenant ON memories(tenant_id, created_at)")
for i in range(500):
cur.execute(
"INSERT INTO memories VALUES (?, ?, ?, ?)",
(f"mem_{i}", f"tenant_{i % 10}", f"Fact content for turn {i}", time.time()),
)
conn.commit()
latencies = []
for _ in range(iterations):
t0 = time.perf_counter()
cur.execute("SELECT content FROM memories WHERE tenant_id = 'tenant_3' ORDER BY created_at DESC LIMIT 5")
cur.fetchall()
latencies.append((time.perf_counter() - t0) * 1000)
conn.close()
return {"status": "measured", **summary(latencies)}
# ---------------------------------------------------------------------------
# Local ChromaDB
# ---------------------------------------------------------------------------
def benchmark_chroma(iterations: int) -> dict:
try:
import chromadb
except ImportError:
return {"status": "not run", "reason": "chromadb is not installed (pip install chromadb)"}
try:
client = chromadb.Client()
collection = client.create_collection(f"bench_{int(time.time() * 1000)}")
docs = [f"Agent decision context for execution turn {i}" for i in range(100)]
collection.add(documents=docs, ids=[f"id_{i}" for i in range(100)])
collection.query(query_texts=["warm up"], n_results=5) # loads the embedding model
latencies = []
for i in range(iterations):
t0 = time.perf_counter()
collection.query(query_texts=[QUERIES[i % len(QUERIES)]], n_results=5)
latencies.append((time.perf_counter() - t0) * 1000)
return {"status": "measured", **summary(latencies)}
except Exception as exc: # noqa: BLE001
return {"status": "not run", "reason": f"{type(exc).__name__}: {exc}"[:200]}
# ---------------------------------------------------------------------------
# MemorySync API
# ---------------------------------------------------------------------------
class _Api:
"""One kept-alive HTTPS connection, the way an SDK's HTTP client behaves.
Opening a new connection per call adds a TLS handshake to every request,
which no real client pays after the first call, so it would measure the
benchmark rather than the service.
"""
def __init__(self, timeout: int = 45) -> None:
self._host = urllib.parse.urlsplit(API).netloc
self._timeout = timeout
self._conn: http.client.HTTPSConnection | None = None
def post(self, path: str, payload: dict, headers: dict) -> tuple[int, dict, float]:
data = json.dumps(payload).encode("utf-8")
all_headers = {"Content-Type": "application/json", "User-Agent": "memory-benchmarks/2.0", **headers}
for attempt in (1, 2):
if self._conn is None:
self._conn = http.client.HTTPSConnection(self._host, timeout=self._timeout)
t0 = time.perf_counter()
try:
self._conn.request("POST", path, body=data, headers=all_headers)
resp = self._conn.getresponse()
body = resp.read().decode("utf-8", errors="replace")
except (http.client.HTTPException, OSError):
# The server closed an idle connection: reconnect once.
self._conn.close()
self._conn = None
if attempt == 2:
raise
continue
elapsed = (time.perf_counter() - t0) * 1000
try:
parsed = json.loads(body)
except ValueError:
parsed = {}
return resp.status, parsed if isinstance(parsed, dict) else {}, elapsed
raise RuntimeError("unreachable")
def benchmark_memorysync(iterations: int) -> dict:
api = _Api()
key = os.environ.get("MEMORYSYNC_API_KEY")
end_user = os.environ.get("MEMORYSYNC_END_USER_ID")
try:
if not key:
status, minted, _ = api.post("/evaluation/keys", {"agent_caller": "memory-benchmarks"}, {})
key = minted.get("api_key")
end_user = end_user or minted.get("default_user_id")
if not key:
return {"status": "not run", "reason": f"evaluation key refused: HTTP {status}"}
except Exception as exc: # noqa: BLE001
return {"status": "not run", "reason": f"could not reach the API: {type(exc).__name__}"}
headers = {"X-API-Key": key, "X-End-User-ID": end_user or "memory-benchmarks"}
seeded = 0
for fact in SEED_FACTS:
try:
status, _, _ = api.post("/memory/add", {"text": fact}, headers)
seeded += status in (200, 201)
except Exception: # noqa: BLE001
pass
if seeded == 0:
return {"status": "not run", "reason": "no facts could be stored"}
time.sleep(3) # new memories take a moment to become searchable
round_trips, server_times, failures = [], [], 0
for i in range(iterations):
try:
status, body, elapsed = api.post("/memory/query", {"query": QUERIES[i % len(QUERIES)], "k": 5}, headers)
except Exception: # noqa: BLE001
failures += 1
continue
if status != 200:
failures += 1
continue
round_trips.append(elapsed)
if isinstance(body.get("latency_ms"), (int, float)):
server_times.append(float(body["latency_ms"]))
time.sleep(0.15)
if not round_trips:
return {"status": "not run", "reason": f"all {failures} queries failed"}
result = {"status": "measured", "failures": failures, "round_trip": summary(round_trips)}
if server_times:
result["server"] = summary(server_times)
return result
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
def _fit_labels_inside(fig, ax, notes: list, pad_px: float = 10.0) -> None:
"""Widen the log x-axis just enough that every value label ends inside the plot.
Matplotlib sizes the axis to the bars, so the label after the longest bar
used to run across the frame. On a log axis a label's width in data units
depends on the limits themselves, so the right limit is solved from the
labels' measured pixel widths instead of a guessed factor.
"""
import math
for _ in range(5):
fig.tight_layout()
fig.canvas.draw()
renderer = fig.canvas.get_renderer()
box = ax.get_window_extent(renderer)
left, right = ax.get_xlim()
lo, hi = math.log10(left), math.log10(right)
needed = hi
for note in notes:
anchor_px = ax.transData.transform(note.xy)[0]
reach_px = note.get_window_extent(renderer).x1 - anchor_px # offset plus text width
usable = box.width - reach_px - pad_px
if usable > 0:
needed = max(needed, lo + (math.log10(note.xy[0]) - lo) * box.width / usable)
if needed <= hi + 1e-6:
return
ax.set_xlim(left, 10**needed)
def render_chart(results: dict, path: str) -> None:
"""One chart, drawn only from what this run measured."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
rows = []
if results["sqlite"]["status"] == "measured":
rows.append(("SQLite (local)", results["sqlite"]))
if results["chromadb"]["status"] == "measured":
rows.append(("ChromaDB (local)", results["chromadb"]))
ms = results["memorysync"]
if ms["status"] == "measured":
if "server" in ms:
rows.append(("MemorySync (server time)", ms["server"]))
rows.append(("MemorySync (round trip)", ms["round_trip"]))
labels = [r[0] for r in rows]
p50 = [max(r[1]["p50_ms"], 0.001) for r in rows]
p95 = [max(r[1]["p95_ms"], 0.001) for r in rows]
fig, ax = plt.subplots(figsize=(10, 5.2), facecolor="white")
y = range(len(rows))
ax.barh([i + 0.2 for i in y], p50, height=0.38, color="#2563eb", label="p50")
ax.barh([i - 0.2 for i in y], p95, height=0.38, color="#93c5fd", label="p95")
ax.set_yticks(list(y))
ax.set_yticklabels(labels)
ax.set_xscale("log")
ax.set_xlabel("Latency (ms, log scale)")
ax.set_title("Measured retrieval latency", fontweight="bold")
def label(v: float) -> str:
if v >= 10:
return f"{v:,.0f} ms"
if v >= 1:
return f"{v:.1f} ms"
return f"{v:.3f} ms"
# Each value sits a fixed 4 points past the end of its bar. An offset in
# points keeps the gap the same however far the axis is widened below.
notes = []
for i, (a, b) in enumerate(zip(p50, p95)):
notes.append(ax.annotate(label(a), xy=(a, i + 0.2), xytext=(4, 0), textcoords="offset points", va="center", fontsize=9))
notes.append(ax.annotate(label(b), xy=(b, i - 0.2), xytext=(4, 0), textcoords="offset points", va="center", fontsize=9))
ax.legend(loc="lower right")
ax.grid(True, axis="x", which="both", ls=":", alpha=0.5)
_fit_labels_inside(fig, ax, notes)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
plt.savefig(path, dpi=160)
plt.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--iterations", type=int, default=50)
parser.add_argument("--skip-remote", action="store_true", help="measure the local backends only")
parser.add_argument("--no-chart", action="store_true")
args = parser.parse_args()
results = {
"iterations": args.iterations,
"sqlite": benchmark_sqlite(args.iterations),
"chromadb": benchmark_chroma(args.iterations),
"memorysync": {"status": "not run", "reason": "--skip-remote"}
if args.skip_remote
else benchmark_memorysync(args.iterations),
}
def line(name: str, r: dict) -> str:
if r.get("status") != "measured":
return f"{name:<28} not run: {r.get('reason')}"
return f"{name:<28} p50 {r['p50_ms']:>10.3f} ms p95 {r['p95_ms']:>10.3f} ms ({r['samples']} samples)"
print(line("SQLite (local)", results["sqlite"]))
print(line("ChromaDB (local)", results["chromadb"]))
ms = results["memorysync"]
if ms["status"] == "measured":
if "server" in ms:
print(line("MemorySync (server time)", {"status": "measured", **ms["server"]}))
print(line("MemorySync (round trip)", {"status": "measured", **ms["round_trip"]}))
else:
print(line("MemorySync", ms))
with open("results.json", "w", encoding="utf-8") as fh:
json.dump(results, fh, indent=2)
if not args.no_chart:
render_chart(results, os.path.join("assets", "measured_latency.png"))
print("wrote results.json and assets/measured_latency.png")
return 0
if __name__ == "__main__":
raise SystemExit(main())