perf(mcp): wire persistent HTTP MCP server + in-memory adata cache + step timing
Browse filesEliminates the per-tool-call subprocess spawn that dominated specialist
latency (~44s/step). The HTTP path existed in-tree but had zero call sites.
- GradioAgentUI.__init__ starts the resident `server.py --transport http`
once per container and registers tools via add_mcp_http(url) in _prewarm
and the per-session rebuild; stdio kept as a health-checked fallback.
- Add agent.add_mcp_http / tool_manager.add_mcp_http_server delegating to
mcp_manager.add_mcp_http; fix its discovery to normalize MCP Tool objects
to dicts (was crashing _process_remote_server with 'Tool' has no 'get').
- Add process-lifetime in-memory AnnData cache (cache.read_h5ad_cached),
keyed by path+mtime+size, returns .copy() so it's safe under the shared
resident server / concurrent sessions; wire into hot DE-path reads.
- Add src/core/perf.py [perf] instrumentation on generate/execute and each
MCP tool call so the win is measurable in logs.
Verified locally: 52 tools register over HTTP, tool calls 26-43ms (vs tens
of seconds stdio), resident process count stays 1, zero stdio spawns. Full
suite 947 passed/55 skipped + new tests/test_inmemory_adata_cache.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/core/perf.py +49 -0
- tests/test_inmemory_adata_cache.py +73 -0
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight per-step performance instrumentation.
|
| 2 |
+
|
| 3 |
+
Cheap timing hooks so the latency win from the persistent HTTP MCP server
|
| 4 |
+
(no per-tool-call subprocess spawn) is measurable rather than anecdotal.
|
| 5 |
+
|
| 6 |
+
Two things are timed:
|
| 7 |
+
- agent generate vs execute phases (src/agent.py)
|
| 8 |
+
- each MCP tool call (src/managers/tools/mcp_manager.py)
|
| 9 |
+
|
| 10 |
+
Each event is printed with a ``[perf]`` prefix (visible in HF Space logs) and
|
| 11 |
+
appended to a bounded module-level ring buffer so a test or a trace dump can
|
| 12 |
+
read the timings back without scraping stdout.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import time
|
| 17 |
+
from collections import deque
|
| 18 |
+
from contextlib import contextmanager
|
| 19 |
+
from typing import Deque, Dict
|
| 20 |
+
|
| 21 |
+
# Bounded so a long-running container can't grow this unboundedly.
|
| 22 |
+
_EVENTS: Deque[Dict] = deque(maxlen=2000)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def record(kind: str, name: str, seconds: float) -> None:
|
| 26 |
+
"""Record a single timed event and echo it to stdout."""
|
| 27 |
+
seconds = round(seconds, 3)
|
| 28 |
+
_EVENTS.append({"kind": kind, "name": name, "seconds": seconds, "t": time.time()})
|
| 29 |
+
print(f"[perf] {kind} {name}: {seconds}s", flush=True)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@contextmanager
|
| 33 |
+
def timed(kind: str, name: str):
|
| 34 |
+
"""Context manager that records monotonic wall time of the block."""
|
| 35 |
+
t0 = time.monotonic()
|
| 36 |
+
try:
|
| 37 |
+
yield
|
| 38 |
+
finally:
|
| 39 |
+
record(kind, name, time.monotonic() - t0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_events() -> list:
|
| 43 |
+
"""Return a snapshot of recorded events (oldest first)."""
|
| 44 |
+
return list(_EVENTS)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def reset() -> None:
|
| 48 |
+
"""Clear recorded events (used by tests)."""
|
| 49 |
+
_EVENTS.clear()
|
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the process-lifetime in-memory AnnData cache (src/cache.py).
|
| 2 |
+
|
| 3 |
+
These guard the two correctness properties the persistent HTTP server relies on:
|
| 4 |
+
1. Each read returns an independent copy, so a caller mutating (or writing
|
| 5 |
+
back) the result cannot corrupt the shared, read-mostly cache — this is
|
| 6 |
+
what makes one resident server safe to share across concurrent sessions.
|
| 7 |
+
2. A rewritten file (newer mtime/size) is transparently re-read instead of
|
| 8 |
+
serving a stale parse.
|
| 9 |
+
"""
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import anndata as ad
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from src import cache
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(autouse=True)
|
| 20 |
+
def _clear_cache():
|
| 21 |
+
cache._MEM_CACHE.clear()
|
| 22 |
+
yield
|
| 23 |
+
cache._MEM_CACHE.clear()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _write(path, fill):
|
| 27 |
+
a = ad.AnnData(X=np.full((10, 5), float(fill)))
|
| 28 |
+
a.write_h5ad(path)
|
| 29 |
+
return a
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_second_read_hits_cache_single_entry(tmp_path):
|
| 33 |
+
p = tmp_path / "x.h5ad"
|
| 34 |
+
_write(p, 1)
|
| 35 |
+
cache.read_h5ad_cached(str(p))
|
| 36 |
+
cache.read_h5ad_cached(str(p))
|
| 37 |
+
assert len(cache._MEM_CACHE) == 1 # one entry, reused
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_returns_independent_copies(tmp_path):
|
| 41 |
+
p = tmp_path / "x.h5ad"
|
| 42 |
+
_write(p, 1)
|
| 43 |
+
a = cache.read_h5ad_cached(str(p))
|
| 44 |
+
b = cache.read_h5ad_cached(str(p))
|
| 45 |
+
assert a is not b
|
| 46 |
+
# Mutating one copy must not affect the cache or other copies.
|
| 47 |
+
a.X[:] = 999
|
| 48 |
+
c = cache.read_h5ad_cached(str(p))
|
| 49 |
+
assert float(c.X.mean()) == 1.0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_rewrite_invalidates_stale_entry(tmp_path):
|
| 53 |
+
p = tmp_path / "x.h5ad"
|
| 54 |
+
_write(p, 1)
|
| 55 |
+
assert float(cache.read_h5ad_cached(str(p)).X.mean()) == 1.0
|
| 56 |
+
time.sleep(1.1) # ensure mtime advances at 1s resolution
|
| 57 |
+
_write(p, 7)
|
| 58 |
+
assert float(cache.read_h5ad_cached(str(p)).X.mean()) == 7.0
|
| 59 |
+
# Stale entry for the same path is evicted, not accumulated.
|
| 60 |
+
assert len(cache._MEM_CACHE) == 1
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_missing_path_falls_through(tmp_path):
|
| 64 |
+
with pytest.raises(Exception):
|
| 65 |
+
cache.read_h5ad_cached(str(tmp_path / "nope.h5ad"))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_eviction_bounds_entries(tmp_path):
|
| 69 |
+
for i in range(cache._MEM_MAX_ENTRIES + 3):
|
| 70 |
+
p = tmp_path / f"d{i}.h5ad"
|
| 71 |
+
_write(p, i)
|
| 72 |
+
cache.read_h5ad_cached(str(p))
|
| 73 |
+
assert len(cache._MEM_CACHE) <= cache._MEM_MAX_ENTRIES
|