Skip to content

Commit 9080528

Browse files
authored
Merge pull request #39 from nickroci/fix/priming-funnel-index-build
fix(priming): funnel index build through inference thread + incremental indexing
2 parents 8fedde0 + 63dda4c commit 9080528

6 files changed

Lines changed: 217 additions & 35 deletions

File tree

daemon/agent_mem_daemon/__main__.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -324,13 +324,12 @@ def _prewarm_indexes(knowledge_root: Path, log: logging.Logger) -> None:
324324
try:
325325
from embeddings import load_or_build as emb_load # noqa: PLC0415
326326

327-
idx = emb_load(knowledge_root)
328-
# Dummy search to JIT-compile the MPS kernels for the query path.
329-
# ``load_or_build`` only loads weights and computes doc vectors — it
330-
# never runs the query-side forward pass, so the first real priming
331-
# request would pay a ~2-3s graph-compile cost without this. Run it on
332-
# the single inference thread so the kernels compile on the thread that
333-
# actually serves requests (else the first real request recompiles).
327+
# Build AND query both run model.encode() — run both on the single
328+
# inference thread so the doc-encode and query-encode kernels compile on
329+
# the thread that actually serves requests (else the first real request
330+
# recompiles), and so warmup never encodes on a different thread than
331+
# serving (the cross-thread case is what deadlocks MPS).
332+
idx = _inference.run(emb_load, knowledge_root)
334333
_inference.run(idx.search, "warmup query for kernel compile", k=1)
335334
log.info("startup: embedding index ready (%d docs)", len(idx.docs))
336335
except Exception:

daemon/agent_mem_daemon/library_tools.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,16 @@ def _run_embedding_search(args: Dict[str, Any], root: Path) -> Dict[str, Any]:
152152
if not root.exists():
153153
return _text_response("(library is empty — no entries to search)")
154154
try:
155-
index = embeddings_load_or_build(root)
155+
# Build + query both run model.encode() → both on the single inference
156+
# thread. The Scholar/Librarian share the MPS model with priming, and a
157+
# stale-index rebuild here re-encodes the whole corpus; doing that on a
158+
# worker thread concurrently with priming deadlocks MPS (see _inference).
159+
raw = _inference.run(lambda: embeddings_load_or_build(root).search(query, k=k))
156160
except FileNotFoundError:
157161
return _text_response("(library has no entries yet)")
158162
except Exception as e:
159-
log.exception("embedding index load/build failed")
163+
log.exception("embedding load/search failed")
160164
return _text_response(f"(embedding backend error: {e})")
161-
# Embedding inference on the daemon's single inference thread — the
162-
# Scholar/Librarian workers share the MPS model with priming (see _inference).
163-
raw = _inference.run(index.search, query, k=k)
164165
as_tuples: list[Tuple[Path, float, str]] = [(h.path, h.score, h.snippet) for h in raw]
165166
return _format_hit_lines(as_tuples, root, query=query)
166167

daemon/agent_mem_daemon/priming.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -386,19 +386,20 @@ def _embedding_search(
386386
return []
387387

388388
try:
389-
index = embeddings_load_or_build(knowledge_dir)
389+
# BOTH the index build and the query encode run model.encode(), so BOTH
390+
# must run on the single inference thread. ``load_or_build`` re-encodes
391+
# the whole corpus whenever the Scholar has written since the last build
392+
# (constant during a session) — running that on the handler pool means
393+
# concurrent priming requests each re-encode all docs on their own
394+
# thread → MPS deadlock. (#37 funnelled the query but not the build,
395+
# which is what actually hangs; confirmed via py-spy.)
396+
hits = _inference.run(
397+
lambda: embeddings_load_or_build(knowledge_dir).search(query, k=max(1, k))
398+
)
390399
except FileNotFoundError:
391400
return []
392401
except Exception:
393-
log.exception("priming: embedding index load/build failed")
394-
return []
395-
396-
try:
397-
# MUST run on the single inference thread — concurrent encode() calls
398-
# from multiple threads deadlock MPS and corrupt tensors (see _inference).
399-
hits = _inference.run(index.search, query, k=max(1, k))
400-
except Exception:
401-
log.exception("priming: embedding search raised")
402+
log.exception("priming: embedding load/search raised")
402403
return []
403404

404405
# Filter out very weak semantic matches (see EMBEDDING_NOISE_FLOOR).

daemon/tests/test_priming_internals.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import threading
1011
from pathlib import Path
1112

1213
import pytest
@@ -235,6 +236,40 @@ def _missing(*args, **kwargs):
235236
assert priming._embedding_search(k, "x", k=5) == []
236237

237238

239+
def test_embedding_search_build_and_query_run_on_inference_thread(
240+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
241+
) -> None:
242+
"""The index BUILD (``embeddings_load_or_build``) and the query must BOTH run
243+
on the single inference thread, not the calling (handler-pool) thread.
244+
245+
Regression guard: #37 funnelled only ``index.search``, leaving the build —
246+
which re-encodes the whole corpus when the index is stale — on the handler
247+
thread. Concurrent priming requests then each rebuild on their own thread,
248+
and concurrent ``model.encode()`` deadlocks MPS (py-spy-confirmed). On the
249+
pre-fix code ``seen["build"]`` is the caller thread and this assertion fails.
250+
"""
251+
kdir = tmp_path / "knowledge"
252+
kdir.mkdir()
253+
seen: dict[str, str] = {}
254+
255+
class _Idx:
256+
def search(self, _query: str, k: int) -> list[object]: # noqa: ARG002
257+
seen["query"] = threading.current_thread().name
258+
return []
259+
260+
def _load(*_args: object, **_kwargs: object) -> _Idx:
261+
seen["build"] = threading.current_thread().name
262+
return _Idx()
263+
264+
monkeypatch.setattr(priming, "embeddings_load_or_build", _load)
265+
caller = threading.current_thread().name
266+
priming._embedding_search(kdir, "x", k=5)
267+
268+
assert seen["build"].startswith("mps-inference"), seen
269+
assert seen["query"].startswith("mps-inference"), seen
270+
assert seen["build"] != caller
271+
272+
238273
def test_embedding_search_handles_search_failure(tmp_path: Path, monkeypatch) -> None:
239274
k = tmp_path / "knowledge"
240275
k.mkdir()

tools/search/embeddings.py

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@
2323
swallows unpickle errors and rebuilds.
2424
2525
Threading:
26-
- Reads (``search``) are safe to call concurrently.
27-
- ``save_index`` is concurrency-safe (see ``bm25.save_pickled``): racing
28-
saves to the same path each write a unique temp file then atomically
29-
replace, so they never interleave into a corrupt file. Last writer wins.
30-
``build_index`` has no shared state, so parallel rebuilds are wasteful
31-
(duplicated encode work) but correct.
26+
- ``search``, ``build_index`` and ``_incremental_build`` all call
27+
``model.encode()``, which is NOT thread-safe on MPS: concurrent encodes from
28+
different threads DEADLOCK (and corrupt each other's tensors). The daemon
29+
serialises ALL inference — search and (incremental) rebuild — onto a single
30+
thread via ``agent_mem_daemon/_inference.py``; callers outside that funnel
31+
must not run these concurrently. (The earlier note here — "parallel rebuilds
32+
are wasteful but correct" — was wrong: they hang.)
33+
- ``save_index`` is concurrency-safe (see ``bm25.save_pickled``): racing saves
34+
to the same path each write a unique temp file then atomically replace, so
35+
they never interleave into a corrupt file. Last writer wins.
3236
"""
3337

3438
from __future__ import annotations
@@ -325,6 +329,90 @@ def build_index(
325329
)
326330

327331

332+
def _incremental_build(
333+
cached: EmbeddingIndex,
334+
knowledge_dir: Path,
335+
model_name: str,
336+
) -> EmbeddingIndex:
337+
"""Rebuild re-encoding ONLY the entries that changed since ``cached``.
338+
339+
Reuses ``cached``'s embedding row for every entry whose path is unchanged and
340+
whose mtime hasn't advanced (matching :func:`is_stale`); encodes only new or
341+
modified entries; drops deleted ones. A full :func:`build_index` re-encodes
342+
the whole corpus — expensive on the priming hot path, which rebuilds after
343+
every Scholar write. The ``model.encode`` here is funnelled onto the daemon's
344+
single inference thread by the callers (``_inference.run``), so it stays
345+
MPS-safe. Falls back to a full build if the cached rows can't be reused.
346+
"""
347+
knowledge_dir = knowledge_dir.expanduser().resolve()
348+
n_cached = cached.embeddings.shape[0] if cached.embeddings.ndim == 2 else 0
349+
by_path = {rec.path: i for i, rec in enumerate(cached.docs)}
350+
351+
docs: list[_DocRecord] = []
352+
rows: list[_FloatArray] = []
353+
encode_texts: list[str] = []
354+
encode_slots: list[int] = []
355+
for md in _iter_markdown(knowledge_dir):
356+
try:
357+
mtime = md.stat().st_mtime
358+
except OSError:
359+
continue
360+
path = str(md)
361+
ci = by_path.get(path)
362+
if ci is not None and ci < n_cached and cached.docs[ci].mtime + 1e-6 >= mtime:
363+
# Unchanged → reuse the cached embedding row (refresh stored mtime so
364+
# the next staleness check is exact).
365+
docs.append(_DocRecord(path=path, mtime=mtime, raw_text=cached.docs[ci].raw_text))
366+
rows.append(cached.embeddings[ci])
367+
continue
368+
try:
369+
raw = md.read_text(encoding="utf-8")
370+
except (OSError, UnicodeDecodeError):
371+
continue
372+
text = _embedding_text(raw).strip()
373+
if not text:
374+
continue
375+
docs.append(_DocRecord(path=path, mtime=mtime, raw_text=raw))
376+
rows.append(np.zeros((0,), dtype=np.float32)) # placeholder; overwritten below
377+
encode_texts.append(text)
378+
encode_slots.append(len(docs) - 1)
379+
380+
if not docs:
381+
return EmbeddingIndex(
382+
knowledge_dir=knowledge_dir,
383+
model_name=model_name,
384+
docs=[],
385+
embeddings=np.zeros((0, 0), dtype=np.float32),
386+
built_at=time.time(),
387+
)
388+
389+
if encode_texts:
390+
model = _load_model(model_name)
391+
fresh = model.encode(
392+
[_format_doc(t, model_name) for t in encode_texts],
393+
convert_to_numpy=True,
394+
normalize_embeddings=True,
395+
show_progress_bar=False,
396+
batch_size=8,
397+
).astype(np.float32)
398+
for i, slot in enumerate(encode_slots):
399+
rows[slot] = fresh[i]
400+
401+
try:
402+
embeddings: _FloatArray = np.vstack(rows).astype(np.float32)
403+
except ValueError:
404+
# Ragged rows (corrupt / dimension-mismatched cache) — full rebuild.
405+
return build_index(knowledge_dir, model_name=model_name)
406+
407+
return EmbeddingIndex(
408+
knowledge_dir=knowledge_dir,
409+
model_name=model_name,
410+
docs=docs,
411+
embeddings=embeddings,
412+
built_at=time.time(),
413+
)
414+
415+
328416
def save_index(index: EmbeddingIndex, path: Path | None = None) -> Path:
329417
"""Pickle the index atomically. Returns the path written.
330418
@@ -343,11 +431,15 @@ def load_or_build(
343431
force_rebuild: bool = False,
344432
index_path: Path | None = None,
345433
) -> EmbeddingIndex:
346-
"""Load the persisted index if fresh, otherwise rebuild and save.
434+
"""Load the persisted index if fresh; otherwise rebuild (incrementally when
435+
possible) and save.
347436
348437
"Fresh" means the index was built against the same ``knowledge_dir`` and
349438
``model_name``, every file we tracked is still present with the same mtime,
350-
and no new ``.md`` files have appeared.
439+
and no new ``.md`` files have appeared. When the cache is compatible but
440+
stale we re-encode ONLY the changed/new entries (see :func:`_incremental_build`)
441+
rather than the whole corpus — the corpus rebuild is what made priming slow
442+
on the hot path after every Scholar write.
351443
"""
352444
knowledge_dir = knowledge_dir.expanduser().resolve()
353445
target = index_path or _default_index_path(knowledge_dir)
@@ -358,12 +450,17 @@ def load_or_build(
358450
cached is not None
359451
and cached.knowledge_dir == knowledge_dir
360452
and cached.model_name == model_name
361-
and not is_stale(
362-
cached.docs,
363-
{str(p): p.stat().st_mtime for p in _iter_markdown(knowledge_dir)},
364-
)
365453
):
366-
return cached
454+
current = {str(p): p.stat().st_mtime for p in _iter_markdown(knowledge_dir)}
455+
if not is_stale(cached.docs, current):
456+
return cached
457+
# Compatible but stale → re-encode only the entries that changed.
458+
index = _incremental_build(cached, knowledge_dir, model_name)
459+
try:
460+
save_index(index, target)
461+
except OSError:
462+
pass
463+
return index
367464

368465
index = build_index(knowledge_dir, model_name=model_name)
369466
try:

tools/search/test_embeddings.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
import time
1616
from pathlib import Path
1717

18+
import numpy as np
1819
import pytest
1920

21+
import embeddings
2022
from embeddings import (
2123
EmbeddingHit,
2224
EmbeddingIndex,
@@ -191,6 +193,53 @@ def test_load_or_build_caches_and_rebuilds(tmp_path: Path) -> None:
191193
assert third.embeddings.shape[0] == len(third.docs) == 5
192194

193195

196+
def test_load_or_build_incremental_reencodes_only_changed(
197+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
198+
) -> None:
199+
"""A stale-but-compatible index re-encodes ONLY the changed entries, not the
200+
whole corpus — the fix for the priming-hot-path full rebuild after every
201+
Scholar write. On the old code this re-encoded everything (batches [3, 3]).
202+
"""
203+
knowledge = tmp_path / "knowledge"
204+
knowledge.mkdir()
205+
for name in ("a", "b", "c"):
206+
(knowledge / f"{name}.md").write_text(f"# {name}\nbody about {name}\n", encoding="utf-8")
207+
208+
batches: list[int] = []
209+
210+
class _FakeModel:
211+
def encode(self, texts: list[str], **_kwargs: object) -> object:
212+
batches.append(len(texts))
213+
arr = np.zeros((len(texts), 4), dtype=np.float32)
214+
for i, text in enumerate(texts):
215+
arr[i, len(text) % 4] = 1.0
216+
return arr
217+
218+
fake = _FakeModel()
219+
monkeypatch.setattr(embeddings, "_load_model", lambda *_a, **_k: fake)
220+
221+
first = load_or_build(knowledge)
222+
assert batches == [3] # full build encoded all 3 entries
223+
assert len(first.docs) == 3
224+
225+
# Modify ONE entry and push its mtime into the future so the index is stale.
226+
(knowledge / "b.md").write_text("# b\ntotally different body now\n", encoding="utf-8")
227+
future = time.time() + 5
228+
os.utime(knowledge / "b.md", (future, future))
229+
230+
second = load_or_build(knowledge)
231+
assert batches == [3, 1] # incremental encoded ONLY the changed entry
232+
assert len(second.docs) == 3
233+
234+
# Unchanged entries kept their exact cached embedding rows (reused, not re-encoded).
235+
def _row(idx: EmbeddingIndex, name: str) -> object:
236+
pos = next(i for i, d in enumerate(idx.docs) if d.path.endswith(name))
237+
return idx.embeddings[pos]
238+
239+
assert np.array_equal(_row(first, "a.md"), _row(second, "a.md"))
240+
assert np.array_equal(_row(first, "c.md"), _row(second, "c.md"))
241+
242+
194243
# ── Edge cases that don't need the model loaded ──────────────────────────────
195244

196245

0 commit comments

Comments
 (0)