|
| 1 | +"""Mining throughput benchmark: per-chunk vs batched upsert, CPU vs GPU. |
| 2 | +
|
| 3 | +Compares the legacy per-chunk ``add_drawer`` loop against the batched |
| 4 | +``collection.upsert`` path introduced in the "batched upsert + GPU" PR. |
| 5 | +Runs both paths on an identical seeded synthetic corpus, reports |
| 6 | +wall-clock time + drawers/sec, and prints a markdown table suitable |
| 7 | +for pasting into a PR description. |
| 8 | +
|
| 9 | +Usage |
| 10 | +----- |
| 11 | +
|
| 12 | + # CPU (whatever onnxruntime is installed — CPU if you don't have |
| 13 | + # onnxruntime-gpu): |
| 14 | + uv run python benchmarks/mine_bench.py |
| 15 | +
|
| 16 | + # GPU (NVIDIA): |
| 17 | + uv venv /tmp/gpu && source /tmp/gpu/bin/activate |
| 18 | + uv pip install -e '.[gpu]' 'nvidia-cudnn-cu12>=9,<10' \\ |
| 19 | + 'nvidia-cuda-runtime-cu12' 'nvidia-cublas-cu12' |
| 20 | + export LD_LIBRARY_PATH=$(python -c "import nvidia.cudnn, os; \\ |
| 21 | + print(os.path.dirname(nvidia.cudnn.__file__)+'/lib')"):$LD_LIBRARY_PATH |
| 22 | + MEMPALACE_EMBEDDING_DEVICE=cuda python benchmarks/mine_bench.py |
| 23 | +
|
| 24 | +Flags |
| 25 | +----- |
| 26 | +
|
| 27 | + --device cpu|cuda|coreml|dml|auto Override MEMPALACE_EMBEDDING_DEVICE |
| 28 | + --scenarios small,medium,large Which scenarios to run |
| 29 | + --seed 42 RNG seed for reproducibility |
| 30 | +""" |
| 31 | + |
| 32 | +from __future__ import annotations |
| 33 | + |
| 34 | +import argparse |
| 35 | +import hashlib |
| 36 | +import os |
| 37 | +import random |
| 38 | +import shutil |
| 39 | +import string |
| 40 | +import sys |
| 41 | +import tempfile |
| 42 | +import time |
| 43 | +from datetime import datetime |
| 44 | +from pathlib import Path |
| 45 | + |
| 46 | + |
| 47 | +def build_corpus(dest: Path, n_files: int, paragraphs_per_file: int, seed: int) -> None: |
| 48 | + """Generate ``n_files`` markdown files of random words under ``dest``.""" |
| 49 | + rng = random.Random(seed) |
| 50 | + dest.mkdir(parents=True, exist_ok=True) |
| 51 | + for i in range(n_files): |
| 52 | + paragraphs = [] |
| 53 | + for _ in range(paragraphs_per_file): |
| 54 | + words = [ |
| 55 | + "".join(rng.choices(string.ascii_lowercase, k=rng.randint(3, 10))) |
| 56 | + for _ in range(12) |
| 57 | + ] |
| 58 | + paragraphs.append(" ".join(words)) |
| 59 | + (dest / f"doc_{i:03d}.md").write_text("\n\n".join(paragraphs)) |
| 60 | + (dest / "mempalace.yaml").write_text( |
| 61 | + "wing: bench\n" |
| 62 | + "rooms:\n" |
| 63 | + " - name: general\n" |
| 64 | + " description: all\n" |
| 65 | + " keywords: [general]\n" |
| 66 | + ) |
| 67 | + |
| 68 | + |
| 69 | +def _process_file_unbatched(filepath, project_path, collection, wing, rooms, agent, closets_col): |
| 70 | + """Legacy per-chunk upsert path (pre-batching). |
| 71 | +
|
| 72 | + Reproduces the exact loop shape the miner used before this PR so the |
| 73 | + comparison is apples-to-apples; only the upsert granularity differs. |
| 74 | + """ |
| 75 | + from mempalace import miner |
| 76 | + from mempalace.palace import ( |
| 77 | + build_closet_lines, |
| 78 | + file_already_mined, |
| 79 | + mine_lock, |
| 80 | + purge_file_closets, |
| 81 | + upsert_closet_lines, |
| 82 | + ) |
| 83 | + |
| 84 | + source_file = str(filepath) |
| 85 | + if file_already_mined(collection, source_file, check_mtime=True): |
| 86 | + return 0, "general" |
| 87 | + try: |
| 88 | + content = filepath.read_text(encoding="utf-8", errors="replace") |
| 89 | + except OSError: |
| 90 | + return 0, "general" |
| 91 | + content = content.strip() |
| 92 | + if len(content) < miner.MIN_CHUNK_SIZE: |
| 93 | + return 0, "general" |
| 94 | + room = miner.detect_room(filepath, content, rooms, project_path) |
| 95 | + chunks = miner.chunk_text(content, source_file) |
| 96 | + |
| 97 | + with mine_lock(source_file): |
| 98 | + if file_already_mined(collection, source_file, check_mtime=True): |
| 99 | + return 0, room |
| 100 | + try: |
| 101 | + collection.delete(where={"source_file": source_file}) |
| 102 | + except Exception: |
| 103 | + pass |
| 104 | + drawers_added = 0 |
| 105 | + for chunk in chunks: |
| 106 | + miner.add_drawer( |
| 107 | + collection=collection, |
| 108 | + wing=wing, |
| 109 | + room=room, |
| 110 | + content=chunk["content"], |
| 111 | + source_file=source_file, |
| 112 | + chunk_index=chunk["chunk_index"], |
| 113 | + agent=agent, |
| 114 | + ) |
| 115 | + drawers_added += 1 |
| 116 | + if closets_col and drawers_added > 0: |
| 117 | + drawer_ids = [ |
| 118 | + f"drawer_{wing}_{room}_" |
| 119 | + f"{hashlib.sha256((source_file + str(c['chunk_index'])).encode()).hexdigest()[:24]}" |
| 120 | + for c in chunks |
| 121 | + ] |
| 122 | + closet_lines = build_closet_lines(source_file, drawer_ids, content, wing, room) |
| 123 | + closet_id_base = ( |
| 124 | + f"closet_{wing}_{room}_" |
| 125 | + f"{hashlib.sha256(source_file.encode()).hexdigest()[:24]}" |
| 126 | + ) |
| 127 | + closet_meta = { |
| 128 | + "wing": wing, |
| 129 | + "room": room, |
| 130 | + "source_file": source_file, |
| 131 | + "drawer_count": drawers_added, |
| 132 | + "filed_at": datetime.now().isoformat(), |
| 133 | + "normalize_version": miner.NORMALIZE_VERSION, |
| 134 | + } |
| 135 | + purge_file_closets(closets_col, source_file) |
| 136 | + upsert_closet_lines(closets_col, closet_id_base, closet_lines, closet_meta) |
| 137 | + return drawers_added, room |
| 138 | + |
| 139 | + |
| 140 | +def mine_once(project_dir: str, palace_path: str, batched: bool) -> tuple[int, float]: |
| 141 | + """Mine a project dir with either the batched (new) or per-chunk (old) path.""" |
| 142 | + from mempalace import miner |
| 143 | + from mempalace.miner import load_config, scan_project |
| 144 | + from mempalace.palace import get_closets_collection, get_collection |
| 145 | + |
| 146 | + project_path = Path(project_dir).resolve() |
| 147 | + config = load_config(project_dir) |
| 148 | + wing = config["wing"] |
| 149 | + rooms = config.get("rooms", []) |
| 150 | + files = scan_project(project_dir) |
| 151 | + collection = get_collection(palace_path) |
| 152 | + closets = get_closets_collection(palace_path) |
| 153 | + |
| 154 | + total = 0 |
| 155 | + t0 = time.perf_counter() |
| 156 | + for filepath in files: |
| 157 | + if batched: |
| 158 | + drawers, _ = miner.process_file( |
| 159 | + filepath=filepath, |
| 160 | + project_path=project_path, |
| 161 | + collection=collection, |
| 162 | + wing=wing, |
| 163 | + rooms=rooms, |
| 164 | + agent="bench", |
| 165 | + dry_run=False, |
| 166 | + closets_col=closets, |
| 167 | + ) |
| 168 | + else: |
| 169 | + drawers, _ = _process_file_unbatched( |
| 170 | + filepath, project_path, collection, wing, rooms, "bench", closets |
| 171 | + ) |
| 172 | + total += drawers |
| 173 | + return total, time.perf_counter() - t0 |
| 174 | + |
| 175 | + |
| 176 | +def _reset_backend_caches() -> None: |
| 177 | + """Drop the in-process client cache so each run pays cold-open cost equally.""" |
| 178 | + from mempalace.palace import _DEFAULT_BACKEND |
| 179 | + |
| 180 | + _DEFAULT_BACKEND._clients.clear() |
| 181 | + _DEFAULT_BACKEND._freshness.clear() |
| 182 | + |
| 183 | + |
| 184 | +def run_scenario(label: str, n_files: int, paragraphs_per_file: int, seed: int) -> dict: |
| 185 | + """Run one scenario under both code paths and return a result dict.""" |
| 186 | + print(f"\n=== {label}: {n_files} files × {paragraphs_per_file} paragraphs ===") |
| 187 | + results = {} |
| 188 | + for mode in ("unbatched", "batched"): |
| 189 | + tmp = Path(tempfile.mkdtemp(prefix=f"mp_{mode}_")) |
| 190 | + try: |
| 191 | + proj = tmp / "proj" |
| 192 | + palace = tmp / "palace" |
| 193 | + build_corpus(proj, n_files, paragraphs_per_file, seed=seed) |
| 194 | + _reset_backend_caches() |
| 195 | + drawers, dt = mine_once(str(proj), str(palace), batched=(mode == "batched")) |
| 196 | + rate = drawers / dt if dt > 0 else 0.0 |
| 197 | + results[mode] = (drawers, dt, rate) |
| 198 | + print(f" {mode:10} {drawers:5} drawers in {dt:6.2f}s → {rate:7.1f} drawers/sec") |
| 199 | + finally: |
| 200 | + shutil.rmtree(tmp, ignore_errors=True) |
| 201 | + |
| 202 | + _, t_u, r_u = results["unbatched"] |
| 203 | + d_b, t_b, r_b = results["batched"] |
| 204 | + speedup = t_u / t_b if t_b > 0 else 0.0 |
| 205 | + print(f" speedup: {speedup:.2f}× ({t_u:.2f}s → {t_b:.2f}s)") |
| 206 | + return { |
| 207 | + "label": label, |
| 208 | + "n_files": n_files, |
| 209 | + "paragraphs": paragraphs_per_file, |
| 210 | + "drawers": d_b, |
| 211 | + "unbatched_time": t_u, |
| 212 | + "unbatched_rate": r_u, |
| 213 | + "batched_time": t_b, |
| 214 | + "batched_rate": r_b, |
| 215 | + "speedup": speedup, |
| 216 | + } |
| 217 | + |
| 218 | + |
| 219 | +SCENARIOS = { |
| 220 | + "small": ("Small files (~50 paragraphs)", 10, 50), |
| 221 | + "medium": ("Medium files (~200 paragraphs)", 20, 200), |
| 222 | + "large": ("Large files (~500 paragraphs)", 10, 500), |
| 223 | +} |
| 224 | + |
| 225 | + |
| 226 | +def _env_summary(device_label: str) -> list[str]: |
| 227 | + """Short hardware + version lines included with the printed table.""" |
| 228 | + import platform |
| 229 | + |
| 230 | + try: |
| 231 | + import chromadb |
| 232 | + |
| 233 | + chromadb_v = chromadb.__version__ |
| 234 | + except Exception: |
| 235 | + chromadb_v = "?" |
| 236 | + try: |
| 237 | + import onnxruntime as ort |
| 238 | + |
| 239 | + ort_v = ort.__version__ |
| 240 | + providers = ",".join(p.replace("ExecutionProvider", "") for p in ort.get_available_providers()) |
| 241 | + except Exception: |
| 242 | + ort_v = "?" |
| 243 | + providers = "?" |
| 244 | + |
| 245 | + return [ |
| 246 | + f"device: **{device_label}** (onnxruntime {ort_v}, providers={providers})", |
| 247 | + f"chromadb {chromadb_v} · python {sys.version.split()[0]} · {platform.platform()}", |
| 248 | + ] |
| 249 | + |
| 250 | + |
| 251 | +def main() -> None: |
| 252 | + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) |
| 253 | + parser.add_argument( |
| 254 | + "--device", |
| 255 | + default=None, |
| 256 | + help="Override MEMPALACE_EMBEDDING_DEVICE (cpu|cuda|coreml|dml|auto)", |
| 257 | + ) |
| 258 | + parser.add_argument( |
| 259 | + "--scenarios", |
| 260 | + default="small,medium,large", |
| 261 | + help="Comma-separated scenario names (default: all)", |
| 262 | + ) |
| 263 | + parser.add_argument("--seed", type=int, default=42) |
| 264 | + args = parser.parse_args() |
| 265 | + |
| 266 | + if args.device: |
| 267 | + os.environ["MEMPALACE_EMBEDDING_DEVICE"] = args.device |
| 268 | + |
| 269 | + from mempalace.embedding import describe_device, get_embedding_function |
| 270 | + |
| 271 | + device_label = describe_device() |
| 272 | + print(f"Warming up ONNX model on device={device_label}...") |
| 273 | + ef = get_embedding_function() |
| 274 | + ef(["warmup sentence one", "warmup sentence two"]) |
| 275 | + |
| 276 | + picked = [s.strip() for s in args.scenarios.split(",") if s.strip()] |
| 277 | + results = [] |
| 278 | + for key in picked: |
| 279 | + if key not in SCENARIOS: |
| 280 | + print(f"Unknown scenario {key!r}; choices: {sorted(SCENARIOS)}", file=sys.stderr) |
| 281 | + sys.exit(2) |
| 282 | + label, n_files, paras = SCENARIOS[key] |
| 283 | + results.append(run_scenario(label, n_files, paras, args.seed)) |
| 284 | + |
| 285 | + print("\n\n## Mining benchmark\n") |
| 286 | + for line in _env_summary(device_label): |
| 287 | + print(line + " ") |
| 288 | + print() |
| 289 | + print("| Scenario | Files | Drawers | Per-chunk (old) | Batched (new) | Speedup |") |
| 290 | + print("| --- | ---: | ---: | ---: | ---: | ---: |") |
| 291 | + for r in results: |
| 292 | + print( |
| 293 | + f"| {r['label']} | {r['n_files']} | {r['drawers']} | " |
| 294 | + f"{r['unbatched_time']:.2f}s · {r['unbatched_rate']:.0f} drw/s | " |
| 295 | + f"{r['batched_time']:.2f}s · {r['batched_rate']:.0f} drw/s | " |
| 296 | + f"**{r['speedup']:.2f}×** |" |
| 297 | + ) |
| 298 | + |
| 299 | + |
| 300 | +if __name__ == "__main__": |
| 301 | + main() |
0 commit comments