Skip to content

Commit a4868a3

Browse files
committed
perf(mining): batch per-chunk upserts and add optional GPU acceleration
The miner upserted one drawer per ChromaDB call, paying tokenizer + ONNX session setup per chunk. The embedding device was CPU-only because no EmbeddingFunction was ever wired through the backend. Two changes, each a speedup in its own right; stacked they give ~10x end-to-end on a medium corpus (20 files, 568 drawers): 1. Batched upsert. `process_file` and `_file_chunks_locked` now collect all chunks of a file into a single `collection.upsert(...)` so the embedding model runs one forward pass per file instead of N. 2. Hardware-accelerated embedding function. New `mempalace/embedding.py` wraps `ONNXMiniLM_L6_V2` with configurable `preferred_providers`. `MEMPALACE_EMBEDDING_DEVICE` (or `embedding_device` in config.json) selects auto / cpu / cuda / coreml / dml. Unavailable accelerators log a warning and fall back to CPU. The factory subclasses `ONNXMiniLM_L6_V2` and spoofs its `name()` to `"default"` so the persisted EF identity matches existing palaces created with ChromaDB's bare `DefaultEmbeddingFunction` -- same model, same 384-dim vectors, no rebuild needed when turning GPU on. `ChromaBackend.get_collection` / `create_collection` now pass the resolved EF on every call so miner writes and searcher reads agree. Benchmarks (i9-12900KF + RTX 3090, medium scenario, 568 drawers): per-chunk + CPU 19.77s · 29 drw/s (baseline) batched + CPU 8.07s · 70 drw/s (2.4x) batched + CUDA 2.15s · 264 drw/s (9.2x) Reproducible via `benchmarks/mine_bench.py`. Install paths: pip install mempalace[gpu] # NVIDIA CUDA pip install mempalace[dml] # DirectML (Windows) pip install mempalace[coreml] # macOS Neural Engine Mine header now prints `Device: cpu|cuda|...` so users can confirm the accelerator engaged.
1 parent 7a75791 commit a4868a3

8 files changed

Lines changed: 784 additions & 61 deletions

File tree

benchmarks/mine_bench.py

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
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()

mempalace/backends/chroma.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,23 @@ def __init__(self):
405405
self._freshness: dict[str, tuple[int, float]] = {}
406406
self._closed = False
407407

408+
@staticmethod
409+
def _resolve_embedding_function():
410+
"""Return the EF for the user's ``embedding_device`` setting.
411+
412+
Both ``get_collection`` and ``get_or_create_collection`` must receive
413+
the EF explicitly — ChromaDB 1.x does not persist it with the
414+
collection, so a reader that omits the argument silently gets the
415+
library default and its queries won't match the writer's vectors.
416+
"""
417+
try:
418+
from ..embedding import get_embedding_function
419+
420+
return get_embedding_function()
421+
except Exception:
422+
logger.exception("Failed to build embedding function; using chromadb default")
423+
return None
424+
408425
# ------------------------------------------------------------------
409426
# Internal helpers
410427
# ------------------------------------------------------------------
@@ -532,12 +549,15 @@ def get_collection(
532549
if options and isinstance(options, dict):
533550
hnsw_space = options.get("hnsw_space", hnsw_space)
534551

552+
ef = self._resolve_embedding_function()
553+
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
554+
535555
if create:
536556
collection = client.get_or_create_collection(
537-
collection_name, metadata={"hnsw:space": hnsw_space}
557+
collection_name, metadata={"hnsw:space": hnsw_space}, **ef_kwargs
538558
)
539559
else:
540-
collection = client.get_collection(collection_name)
560+
collection = client.get_collection(collection_name, **ef_kwargs)
541561
return ChromaCollection(collection)
542562

543563
def close_palace(self, palace) -> None:
@@ -578,8 +598,10 @@ def create_collection(
578598
self, palace_path: str, collection_name: str, hnsw_space: str = "cosine"
579599
) -> ChromaCollection:
580600
"""Create (not get-or-create) ``collection_name`` with the given HNSW space."""
601+
ef = self._resolve_embedding_function()
602+
ef_kwargs = {"embedding_function": ef} if ef is not None else {}
581603
collection = self._client(palace_path).create_collection(
582-
collection_name, metadata={"hnsw:space": hnsw_space}
604+
collection_name, metadata={"hnsw:space": hnsw_space}, **ef_kwargs
583605
)
584606
return ChromaCollection(collection)
585607

mempalace/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,23 @@ def set_entity_languages(self, languages):
236236
pass
237237
return normalized
238238

239+
@property
240+
def embedding_device(self):
241+
"""Hardware device for the ONNX embedding model.
242+
243+
Values: ``"auto"`` (default), ``"cpu"``, ``"cuda"``, ``"coreml"``,
244+
``"dml"``. Read from env ``MEMPALACE_EMBEDDING_DEVICE`` first, then
245+
``embedding_device`` in ``config.json``, then ``"auto"``.
246+
247+
``auto`` resolves to the first available accelerator at runtime via
248+
:mod:`mempalace.embedding`; requesting an unavailable accelerator
249+
logs a warning and falls back to CPU.
250+
"""
251+
env_val = os.environ.get("MEMPALACE_EMBEDDING_DEVICE")
252+
if env_val:
253+
return env_val.strip().lower()
254+
return str(self._file_config.get("embedding_device", "auto")).strip().lower()
255+
239256
@property
240257
def hook_silent_save(self):
241258
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""

0 commit comments

Comments
 (0)