Skip to content

Commit d2074c1

Browse files
authored
Merge pull request #37 from nickroci/fix/priming-mps-single-thread-inference
fix(priming): serialize MPS inference on one thread; real doctor probe
2 parents c4babbb + a1ab431 commit d2074c1

6 files changed

Lines changed: 147 additions & 15 deletions

File tree

daemon/agent_mem_daemon/__main__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from pathlib import Path
2626
from types import FrameType
2727

28+
from . import _inference
2829
from .buffer import DEFAULT_INACTIVITY_SECONDS, DEFAULT_MAX_TURNS, RollingBuffer
2930
from .ingest import DEFAULT_POLL_INTERVAL, JsonlTailer
3031
from .logging_setup import configure as configure_logging
@@ -327,8 +328,10 @@ def _prewarm_indexes(knowledge_root: Path, log: logging.Logger) -> None:
327328
# Dummy search to JIT-compile the MPS kernels for the query path.
328329
# ``load_or_build`` only loads weights and computes doc vectors — it
329330
# never runs the query-side forward pass, so the first real priming
330-
# request would pay a ~2-3s graph-compile cost without this.
331-
idx.search("warmup query for kernel compile", k=1)
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).
334+
_inference.run(idx.search, "warmup query for kernel compile", k=1)
332335
log.info("startup: embedding index ready (%d docs)", len(idx.docs))
333336
except Exception:
334337
log.exception("startup: embedding prewarm failed (lazy rebuild on first use)")
@@ -477,6 +480,9 @@ def run(args: argparse.Namespace) -> int:
477480
# next start. Last — the hook can keep getting priming until
478481
# the moment we shut down.
479482
rpc_thread.stop(timeout=2.0)
483+
# RPC stopped → no more inference requests; stop the single inference
484+
# thread so the executor's at-exit join doesn't delay process exit.
485+
_inference.shutdown()
480486
log.info(
481487
"shutdown complete: stats=librarian_runs=%d backpressure_skips=%d "
482488
"librarian_debounced=%d scholar_runs=%d packets_queued=%d "
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Single-thread executor for the daemon's PyTorch/MPS model inference.
2+
3+
The sentence-transformer embedder and the cross-encoder reranker run on MPS
4+
(Apple-Silicon GPU). PyTorch MPS is NOT safe to drive from multiple threads:
5+
concurrent forward passes from different threads share the model's internal
6+
buffers, which both
7+
8+
1. **deadlocks** after the first cross-thread call (the priming RPC served
9+
exactly one request, then every later request hung), and
10+
2. **corrupts** intermediate tensors — observed as
11+
``RuntimeError: size of tensor a (45) must match tensor b (35) at
12+
non-singleton dimension 1`` (one query's sequence length colliding with
13+
another's attention mask).
14+
15+
Several daemon threads want to run inference: the priming RPC handler pool, the
16+
Scholar/Librarian agent-research workers (embedding search via
17+
``library_tools``), and the startup warmup. Funnel EVERY model forward pass
18+
through the one dedicated thread here, so MPS is only ever driven from a single
19+
thread. Connection I/O and BM25 (CPU) stay concurrent; only the model calls
20+
serialise.
21+
22+
Wrap LEAF model calls only (``index.search`` / ``rerank`` / ``encode``), never a
23+
whole pipeline that itself calls :func:`run` — submitting to this 1-worker pool
24+
from its own worker thread would deadlock. The startup warmup must also go
25+
through :func:`run` so the MPS kernels JIT-compile on the serving thread.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import threading
31+
from concurrent.futures import ThreadPoolExecutor
32+
from typing import Callable, Optional, ParamSpec, TypeVar
33+
34+
_P = ParamSpec("_P")
35+
_T = TypeVar("_T")
36+
37+
# Lazily-created single-worker executor. Re-creatable: :func:`shutdown` resets it
38+
# so a fresh one is built on the next :func:`run` — without this, a shutdown
39+
# (daemon exit, or a test exercising it) would poison every later call with
40+
# "cannot schedule new futures after shutdown".
41+
_lock = threading.Lock()
42+
_executor: Optional[ThreadPoolExecutor] = None
43+
44+
45+
def _get_executor() -> ThreadPoolExecutor:
46+
global _executor
47+
with _lock:
48+
if _executor is None:
49+
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="mps-inference")
50+
return _executor
51+
52+
53+
def run(fn: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> _T:
54+
"""Run a leaf model-inference callable on the single inference thread and
55+
return its result (blocking the caller until it completes).
56+
57+
MUST wrap only leaf calls (``encode`` / ``predict`` / ``index.search`` /
58+
``rerank``); never a pipeline that re-enters :func:`run`, which would submit
59+
to this 1-worker pool from its own worker thread and deadlock.
60+
"""
61+
return _get_executor().submit(fn, *args, **kwargs).result()
62+
63+
64+
def shutdown() -> None:
65+
"""Stop the inference thread (daemon shutdown) so the executor's at-exit
66+
join doesn't delay process exit. Idempotent; a later :func:`run` lazily
67+
recreates the executor."""
68+
global _executor
69+
with _lock:
70+
ex, _executor = _executor, None
71+
if ex is not None:
72+
ex.shutdown(wait=False, cancel_futures=True)

daemon/agent_mem_daemon/library_tools.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
from claude_agent_sdk import create_sdk_mcp_server, tool
2828
from embeddings import load_or_build as embeddings_load_or_build
2929

30+
from . import _inference
31+
3032
if TYPE_CHECKING:
3133
from claude_agent_sdk.types import McpServerConfig
3234

@@ -156,7 +158,9 @@ def _run_embedding_search(args: Dict[str, Any], root: Path) -> Dict[str, Any]:
156158
except Exception as e:
157159
log.exception("embedding index load/build failed")
158160
return _text_response(f"(embedding backend error: {e})")
159-
raw = index.search(query, k=k)
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)
160164
as_tuples: list[Tuple[Path, float, str]] = [(h.path, h.score, h.snippet) for h in raw]
161165
return _format_hit_lines(as_tuples, root, query=query)
162166

daemon/agent_mem_daemon/priming.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from embeddings import load_or_build as embeddings_load_or_build
5656
from reranker import rerank as _cross_encoder_rerank
5757

58+
from . import _inference
5859
from .paths import home as _agent_mem_home
5960

6061
log = logging.getLogger("agent_mem_daemon.priming")
@@ -393,7 +394,9 @@ def _embedding_search(
393394
return []
394395

395396
try:
396-
hits = index.search(query, k=max(1, k))
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))
397400
except Exception:
398401
log.exception("priming: embedding search raised")
399402
return []
@@ -505,7 +508,8 @@ def _rerank_candidates(
505508
if not bodies:
506509
return candidates[:k]
507510

508-
reranked = _cross_encoder_rerank(query, bodies)
511+
# Cross-encoder predict on the single inference thread (MPS thread-safety).
512+
reranked = _inference.run(_cross_encoder_rerank, query, bodies)
509513
if reranked is None:
510514
return candidates[:k]
511515
above_floor = [(p, s) for p, s in reranked if s >= _RERANK_SCORE_FLOOR]

ultan/_doctor.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ def _daemon_phase(home: Path) -> dict[str, Any] | None:
6868
return cast("dict[str, Any]", raw) if isinstance(raw, dict) else None
6969

7070

71-
def _report_daemon(home: Path) -> tuple[bool, bool]:
72-
"""Process + socket + priming round-trip. Returns (alive, socket_ok)."""
71+
def _report_daemon(home: Path) -> tuple[bool, bool, bool]:
72+
"""Process + socket + REAL priming probe. Returns (alive, socket_ok, priming_ok),
73+
where priming_ok means the daemon itself answered a real priming request
74+
(not that the socket merely accepts connections)."""
7375
pid = _daemon_pid(home)
7476
alive = pid is not None and _daemon._pid_alive(pid) # pyright: ignore[reportPrivateUsage]
7577
print(f"daemon process: {'running (pid ' + str(pid) + ')' if alive else 'not running'}")
@@ -101,12 +103,27 @@ def _report_daemon(home: Path) -> tuple[bool, bool]:
101103
else:
102104
print("priming socket: absent (daemon lazy-starts on the next prompt)")
103105

104-
t0 = time.monotonic()
105-
md = _priming.get_priming("doctor health probe: packaging daemon memory", k=1)
106-
dt_ms = (time.monotonic() - t0) * 1000.0
107-
lane = "daemon" if socket_ok else "lexical fallback"
108-
print(f"priming round-trip: {'ok' if md else 'no matches'} ({dt_ms:.0f}ms, {lane})")
109-
return alive, socket_ok
106+
# Send a REAL priming request — the socket accepting a connection (above)
107+
# does NOT mean the priming handler works: a hung RPC (serve-one-then-
108+
# deadlock) still accepts connects but never answers. probe_daemon does not
109+
# fall back to the lexical scan, so None here == the daemon didn't respond.
110+
priming_ok = False
111+
if socket_ok:
112+
t0 = time.monotonic()
113+
resp = _priming.probe_daemon("doctor health probe: packaging daemon memory")
114+
dt_ms = (time.monotonic() - t0) * 1000.0
115+
if resp is not None and resp.get("ok"):
116+
priming_ok = True
117+
print(
118+
f"priming probe: daemon answered ({dt_ms:.0f}ms client / "
119+
f"{resp.get('took_ms')}ms server, lane={resp.get('lane')})"
120+
)
121+
else:
122+
print(
123+
f"priming probe: DAEMON DID NOT ANSWER in {dt_ms:.0f}ms — the priming "
124+
"RPC is hung; the hook falls back to the lexical scan (priming DEGRADED)"
125+
)
126+
return alive, socket_ok, priming_ok
110127

111128

112129
def _report_storage(home: Path) -> None:
@@ -134,13 +151,18 @@ def _report_storage(home: Path) -> None:
134151
def run() -> int:
135152
home = _daemon._home() # pyright: ignore[reportPrivateUsage] # intra-package
136153
broken = _report_runtime(home)
137-
alive, socket_ok = _report_daemon(home)
154+
alive, socket_ok, priming_ok = _report_daemon(home)
138155
_report_storage(home)
139156

140157
if broken:
141158
verdict = "BROKEN — runtime deps missing (see above)"
142-
elif socket_ok:
159+
elif priming_ok:
143160
verdict = "HEALTHY"
161+
elif socket_ok:
162+
verdict = (
163+
"DEGRADED — daemon up but the priming RPC isn't answering; the hook falls back "
164+
"to the in-process lexical scan (see 'priming probe' above)"
165+
)
144166
elif alive:
145167
verdict = "WARMING — daemon is loading models; priming uses the lexical fallback meanwhile"
146168
else:

ultan/_priming.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,3 +536,27 @@ def get_priming(
536536
char_budget=char_budget,
537537
deadline=max(deadline, time.monotonic() + _FALLBACK_FLOOR_S),
538538
)
539+
540+
541+
def probe_daemon(prompt: str, *, budget_ms: int = 5000) -> Optional[PrimingResponse]:
542+
"""Send a real priming request to the daemon and return its raw response,
543+
or ``None`` if the daemon didn't answer within ``budget_ms`` (or the socket
544+
is absent).
545+
546+
Unlike :func:`get_priming`, this does NOT fall back to the lexical scan —
547+
``ultan doctor`` needs to know whether the DAEMON itself responded, not
548+
whether *some* lane produced text. A hung priming RPC (the daemon serving
549+
one request then deadlocking) shows up here as ``None``.
550+
"""
551+
socket_path = _priming_socket_path()
552+
if not socket_path.exists():
553+
return None
554+
request: PrimingRequest = {
555+
"op": "priming",
556+
"prompt": prompt,
557+
"project_slug": None,
558+
"session_id": None,
559+
"k": 1,
560+
"char_budget": 200,
561+
}
562+
return _send_request(socket_path, request, total_budget_ms=budget_ms)

0 commit comments

Comments
 (0)