mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-14 13:17:29 +08:00
Add --transport=cli|mcp to the incremental speed benchmark. The MCP path keeps one JSON-RPC server process alive for each measurement phase and detects full versus exact incremental routes from index_repository publish_kind metadata instead of stderr logs. The harness still preserves the cold CLI path and cleanup behavior, and reports publish_kind alongside existing wall/logged timing fields for current and future performance comparisons. Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
514 lines
18 KiB
Python
Executable File
514 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Measure fast-mode exact incremental indexing against a fresh full rebuild.
|
|
|
|
This is an explicit opt-in performance gate. It creates a synthetic Go repo in
|
|
a temporary work root, uses an isolated CBM_CACHE_DIR, enables disk incremental
|
|
indexing only for that cache, and removes only paths it created.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
DEFAULT_FILE_COUNT = 240
|
|
DEFAULT_FUNCTIONS_PER_FILE = 12
|
|
DEFAULT_CHANGED_FILES = 2
|
|
DEFAULT_MIN_SPEEDUP = 10.0
|
|
DEFAULT_TIMEOUT_SECONDS = 240
|
|
DEFAULT_RANK_REFRESH = "stale_on_exact"
|
|
PROJECT_DB_SUFFIX = ".db"
|
|
CONFIG_DB_NAME = "_config.db"
|
|
LOG_TAIL_LINES = 24
|
|
MCP_INIT_PROTOCOL_VERSION = "2024-11-05"
|
|
PUBLISH_FULL = "full"
|
|
PUBLISH_INCREMENTAL_NOOP = "incremental_noop"
|
|
PUBLISH_INCREMENTAL_EXACT = "incremental_exact"
|
|
PUBLISH_INCREMENTAL_CONTAINMENT = "incremental_containment"
|
|
|
|
|
|
def now_ms() -> float:
|
|
return time.perf_counter() * 1000.0
|
|
|
|
|
|
def write_text(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def go_file_content(index: int, revision: int, funcs_per_file: int) -> str:
|
|
lines = ["package main", ""]
|
|
for func_index in range(funcs_per_file):
|
|
value = index * funcs_per_file + func_index + revision
|
|
lines.extend(
|
|
[
|
|
f"func Func{index:04d}_{func_index:02d}() int {{",
|
|
f"\treturn {value}",
|
|
"}",
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def create_repo(repo_dir: Path, file_count: int, funcs_per_file: int) -> None:
|
|
write_text(repo_dir / "go.mod", "module example.com/cbmbench\n\ngo 1.22\n")
|
|
write_text(repo_dir / "main.go", "package main\n\nfunc main() {}\n")
|
|
for index in range(file_count):
|
|
write_text(repo_dir / f"pkg/file_{index:04d}.go", go_file_content(index, 0, funcs_per_file))
|
|
|
|
|
|
def modify_existing_files(repo_dir: Path, changed_files: int, funcs_per_file: int) -> list[str]:
|
|
changed: list[str] = []
|
|
for index in range(changed_files):
|
|
rel = Path("pkg") / f"file_{index:04d}.go"
|
|
write_text(repo_dir / rel, go_file_content(index, 1000, funcs_per_file))
|
|
changed.append(rel.as_posix())
|
|
return changed
|
|
|
|
|
|
def command_result(
|
|
cmd: list[str],
|
|
env: dict[str, str],
|
|
timeout: int,
|
|
cwd: Path | None = None,
|
|
) -> tuple[subprocess.CompletedProcess[str], float]:
|
|
start = now_ms()
|
|
proc = subprocess.run(
|
|
cmd,
|
|
cwd=str(cwd) if cwd else None,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
return proc, now_ms() - start
|
|
|
|
|
|
def unwrap_cli_json(stdout: str) -> dict[str, Any]:
|
|
outer = json.loads(stdout)
|
|
if "content" in outer:
|
|
return json.loads(outer["content"][0]["text"])
|
|
return outer
|
|
|
|
|
|
def unwrap_mcp_result(response: dict[str, Any]) -> dict[str, Any]:
|
|
result = response.get("result", {})
|
|
if "content" in result:
|
|
return json.loads(result["content"][0]["text"])
|
|
return result
|
|
|
|
|
|
class McpClient:
|
|
def __init__(self, binary: Path, env: dict[str, str], timeout: int) -> None:
|
|
self.binary = binary
|
|
self.env = env
|
|
self.timeout = timeout
|
|
self.next_id = 1
|
|
self.stderr_lines: list[str] = []
|
|
self.stderr_lock = threading.Lock()
|
|
self.stdout_queue: queue.Queue[str | None] = queue.Queue()
|
|
self.proc: subprocess.Popen[str] | None = None
|
|
self.stdout_thread: threading.Thread | None = None
|
|
self.stderr_thread: threading.Thread | None = None
|
|
|
|
def __enter__(self) -> "McpClient":
|
|
self.proc = subprocess.Popen(
|
|
[str(self.binary)],
|
|
env=self.env,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
self.stdout_thread = threading.Thread(target=self._read_stdout, daemon=True)
|
|
self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
|
self.stdout_thread.start()
|
|
self.stderr_thread.start()
|
|
self._initialize()
|
|
return self
|
|
|
|
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
if not self.proc:
|
|
return
|
|
if self.proc.stdin:
|
|
self.proc.stdin.close()
|
|
try:
|
|
self.proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
self.proc.wait(timeout=5)
|
|
|
|
def _read_stdout(self) -> None:
|
|
assert self.proc and self.proc.stdout
|
|
for line in self.proc.stdout:
|
|
self.stdout_queue.put(line)
|
|
self.stdout_queue.put(None)
|
|
|
|
def _read_stderr(self) -> None:
|
|
assert self.proc and self.proc.stderr
|
|
for line in self.proc.stderr:
|
|
with self.stderr_lock:
|
|
self.stderr_lines.append(line.rstrip("\n"))
|
|
|
|
def _stderr_mark(self) -> int:
|
|
with self.stderr_lock:
|
|
return len(self.stderr_lines)
|
|
|
|
def _stderr_since(self, mark: int) -> str:
|
|
with self.stderr_lock:
|
|
return "\n".join(self.stderr_lines[mark:])
|
|
|
|
def _send(self, message: dict[str, Any]) -> None:
|
|
if not self.proc or not self.proc.stdin:
|
|
raise RuntimeError("MCP server is not running")
|
|
self.proc.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
|
|
self.proc.stdin.flush()
|
|
|
|
def _request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
req_id = self.next_id
|
|
self.next_id += 1
|
|
message: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
|
|
if params is not None:
|
|
message["params"] = params
|
|
self._send(message)
|
|
|
|
deadline = time.monotonic() + self.timeout
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError(f"MCP request timed out: {method}")
|
|
line = self.stdout_queue.get(timeout=remaining)
|
|
if line is None:
|
|
raise RuntimeError(f"MCP server exited before response: {method}")
|
|
try:
|
|
response = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"non-JSON MCP stdout line: {line[:200]!r}") from exc
|
|
if response.get("id") == req_id:
|
|
if "error" in response:
|
|
raise RuntimeError(f"MCP request failed: {response['error']}")
|
|
return response
|
|
|
|
def _notification(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
if params is not None:
|
|
message["params"] = params
|
|
self._send(message)
|
|
|
|
def _initialize(self) -> None:
|
|
self._request(
|
|
"initialize",
|
|
{
|
|
"protocolVersion": MCP_INIT_PROTOCOL_VERSION,
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "cbm-incr-speed", "version": "1.0"},
|
|
},
|
|
)
|
|
self._notification("notifications/initialized")
|
|
|
|
def call_tool(
|
|
self, name: str, arguments: dict[str, Any]
|
|
) -> tuple[dict[str, Any], str, int, float]:
|
|
mark = self._stderr_mark()
|
|
start = now_ms()
|
|
response = self._request("tools/call", {"name": name, "arguments": arguments})
|
|
elapsed_ms = now_ms() - start
|
|
stderr = self._stderr_since(mark)
|
|
stdout_bytes = len(json.dumps(response, separators=(",", ":")).encode("utf-8"))
|
|
return unwrap_mcp_result(response), stderr, stdout_bytes, elapsed_ms
|
|
|
|
|
|
def log_tail(stderr: str) -> list[str]:
|
|
lines = stderr.splitlines()
|
|
return lines[-LOG_TAIL_LINES:]
|
|
|
|
|
|
def log_has(stderr: str, marker: str) -> bool:
|
|
return marker in stderr
|
|
|
|
|
|
def response_publish_kind(data: dict[str, Any]) -> str:
|
|
publish_kind = data.get("publish_kind")
|
|
return publish_kind if isinstance(publish_kind, str) else ""
|
|
|
|
|
|
def is_incremental_publish_kind(publish_kind: str) -> bool:
|
|
return publish_kind in {
|
|
PUBLISH_INCREMENTAL_NOOP,
|
|
PUBLISH_INCREMENTAL_EXACT,
|
|
PUBLISH_INCREMENTAL_CONTAINMENT,
|
|
}
|
|
|
|
|
|
def parse_logged_elapsed_ms(stderr: str, marker: str) -> int | None:
|
|
for line in stderr.splitlines():
|
|
if marker not in line:
|
|
continue
|
|
for item in line.split():
|
|
if item.startswith("elapsed_ms="):
|
|
try:
|
|
return int(item.split("=", 1)[1])
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def indexed_work_elapsed_ms(logged_elapsed_ms: dict[str, int | None]) -> int | None:
|
|
incremental_ms = logged_elapsed_ms.get("incremental_done")
|
|
if incremental_ms is not None:
|
|
return incremental_ms
|
|
return logged_elapsed_ms.get("pipeline_done")
|
|
|
|
|
|
def run_config_set(binary: Path, env: dict[str, str], key: str, value: str, timeout: int) -> None:
|
|
proc, _ = command_result([str(binary), "config", "set", key, value], env, timeout)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"config set {key} failed: {proc.stderr.strip()}")
|
|
|
|
|
|
def build_index_result(
|
|
data: dict[str, Any],
|
|
stderr: str,
|
|
stdout_bytes: int,
|
|
elapsed_ms: float,
|
|
include_logs: bool,
|
|
) -> dict[str, Any]:
|
|
elapsed_ms_int = int(elapsed_ms)
|
|
publish_kind = response_publish_kind(data)
|
|
logged_elapsed_ms = {
|
|
"pipeline_done": parse_logged_elapsed_ms(stderr, "pipeline.done"),
|
|
"incremental_done": parse_logged_elapsed_ms(stderr, "incremental.done"),
|
|
}
|
|
indexed_ms = indexed_work_elapsed_ms(logged_elapsed_ms)
|
|
result: dict[str, Any] = {
|
|
"elapsed_ms": elapsed_ms_int,
|
|
"indexed_work_elapsed_ms": indexed_ms,
|
|
"unlogged_overhead_ms": (elapsed_ms_int - indexed_ms) if indexed_ms is not None else None,
|
|
"response": data,
|
|
"publish_kind": publish_kind or None,
|
|
"stdout_bytes": stdout_bytes,
|
|
"markers": {
|
|
"incremental_exact_done": log_has(stderr, "incremental.exact.done")
|
|
or publish_kind == PUBLISH_INCREMENTAL_EXACT,
|
|
"incremental_done": log_has(stderr, "incremental.done")
|
|
or is_incremental_publish_kind(publish_kind),
|
|
"pagerank_done": log_has(stderr, "pagerank.done"),
|
|
"pagerank_defer": log_has(stderr, "pagerank.defer"),
|
|
"full_route": log_has(stderr, "pipeline.route path=full")
|
|
or publish_kind == PUBLISH_FULL,
|
|
"incremental_route": log_has(stderr, "pipeline.route path=incremental")
|
|
or is_incremental_publish_kind(publish_kind),
|
|
},
|
|
"logged_elapsed_ms": logged_elapsed_ms,
|
|
"stderr_tail": log_tail(stderr),
|
|
}
|
|
if include_logs:
|
|
result["stderr"] = stderr
|
|
return result
|
|
|
|
|
|
def run_index(
|
|
binary: Path,
|
|
env: dict[str, str],
|
|
repo_dir: Path,
|
|
timeout: int,
|
|
include_logs: bool,
|
|
) -> dict[str, Any]:
|
|
args = json.dumps({"repo_path": str(repo_dir), "mode": "fast"})
|
|
proc, elapsed_ms = command_result(
|
|
[str(binary), "cli", "--json", "index_repository", args],
|
|
env,
|
|
timeout,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"index_repository failed: {proc.stderr.strip()}")
|
|
data = unwrap_cli_json(proc.stdout)
|
|
return build_index_result(
|
|
data, proc.stderr, len(proc.stdout.encode("utf-8")), elapsed_ms, include_logs
|
|
)
|
|
|
|
|
|
def run_index_mcp(client: McpClient, repo_dir: Path, include_logs: bool) -> dict[str, Any]:
|
|
data, stderr, stdout_bytes, elapsed_ms = client.call_tool(
|
|
"index_repository", {"repo_path": str(repo_dir), "mode": "fast"}
|
|
)
|
|
return build_index_result(data, stderr, stdout_bytes, elapsed_ms, include_logs)
|
|
|
|
|
|
def remove_project_dbs(cache_dir: Path) -> list[str]:
|
|
removed: list[str] = []
|
|
for path in cache_dir.iterdir():
|
|
if not path.is_file():
|
|
continue
|
|
if path.name == CONFIG_DB_NAME or not path.name.endswith(PROJECT_DB_SUFFIX):
|
|
continue
|
|
path.unlink()
|
|
removed.append(path.name)
|
|
for suffix in ("-wal", "-shm"):
|
|
sidecar = cache_dir / f"{path.name}{suffix}"
|
|
if sidecar.exists():
|
|
sidecar.unlink()
|
|
removed.append(sidecar.name)
|
|
return removed
|
|
|
|
|
|
def build_env(cache_dir: Path) -> dict[str, str]:
|
|
env = dict(os.environ)
|
|
env["CBM_CACHE_DIR"] = str(cache_dir)
|
|
env["CBM_AUTO_INDEX"] = "false"
|
|
env["CBM_CONTEXT_INJECTION"] = "false"
|
|
return env
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Gate exact fast-mode incremental indexing against a fresh full rebuild."
|
|
)
|
|
parser.add_argument("--binary", default="build/c/codebase-memory-mcp")
|
|
parser.add_argument("--work-root", default="")
|
|
parser.add_argument("--out", default="")
|
|
parser.add_argument("--files", type=int, default=DEFAULT_FILE_COUNT)
|
|
parser.add_argument("--functions-per-file", type=int, default=DEFAULT_FUNCTIONS_PER_FILE)
|
|
parser.add_argument("--changed-files", type=int, default=DEFAULT_CHANGED_FILES)
|
|
parser.add_argument("--min-speedup", type=float, default=DEFAULT_MIN_SPEEDUP)
|
|
parser.add_argument(
|
|
"--rank-refresh",
|
|
choices=("eager", "stale_on_exact"),
|
|
default=DEFAULT_RANK_REFRESH,
|
|
)
|
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS)
|
|
parser.add_argument("--keep-work-root", action="store_true")
|
|
parser.add_argument("--include-logs", action="store_true")
|
|
parser.add_argument(
|
|
"--transport",
|
|
choices=("cli", "mcp"),
|
|
default="cli",
|
|
help="Measure cold CLI subprocess calls or persistent MCP tool-call latency.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
binary = Path(args.binary).expanduser()
|
|
if not binary.is_absolute():
|
|
binary = Path.cwd() / binary
|
|
binary = binary.resolve()
|
|
if not binary.is_file():
|
|
print(f"error: binary not found: {binary}", file=sys.stderr)
|
|
return 2
|
|
|
|
auto_root = not bool(args.work_root)
|
|
work_root = Path(args.work_root).expanduser() if args.work_root else Path(
|
|
tempfile.mkdtemp(prefix="cbm-incr-speed-")
|
|
)
|
|
work_root.mkdir(parents=True, exist_ok=True)
|
|
repo_dir = work_root / "repo"
|
|
cache_dir = work_root / "cache"
|
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
report: dict[str, Any] = {
|
|
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
"binary": str(binary),
|
|
"work_root": str(work_root),
|
|
"parameters": {
|
|
"files": args.files,
|
|
"functions_per_file": args.functions_per_file,
|
|
"changed_files": args.changed_files,
|
|
"min_speedup": args.min_speedup,
|
|
"rank_refresh": args.rank_refresh,
|
|
"timeout": args.timeout,
|
|
"transport": args.transport,
|
|
},
|
|
"cleanup": {"requested": auto_root and not args.keep_work_root, "removed": False},
|
|
}
|
|
|
|
exit_code = 1
|
|
try:
|
|
create_repo(repo_dir, args.files, args.functions_per_file)
|
|
env = build_env(cache_dir)
|
|
run_config_set(binary, env, "incremental_reindex", "always", args.timeout)
|
|
run_config_set(binary, env, "rank_refresh", args.rank_refresh, args.timeout)
|
|
|
|
if args.transport == "mcp":
|
|
with McpClient(binary, env, args.timeout) as client:
|
|
initial = run_index_mcp(client, repo_dir, args.include_logs)
|
|
changed_paths = modify_existing_files(
|
|
repo_dir, args.changed_files, args.functions_per_file
|
|
)
|
|
incremental = run_index_mcp(client, repo_dir, args.include_logs)
|
|
removed_dbs = remove_project_dbs(cache_dir)
|
|
with McpClient(binary, env, args.timeout) as client:
|
|
full_rebuild = run_index_mcp(client, repo_dir, args.include_logs)
|
|
else:
|
|
initial = run_index(binary, env, repo_dir, args.timeout, args.include_logs)
|
|
changed_paths = modify_existing_files(
|
|
repo_dir, args.changed_files, args.functions_per_file
|
|
)
|
|
incremental = run_index(binary, env, repo_dir, args.timeout, args.include_logs)
|
|
removed_dbs = remove_project_dbs(cache_dir)
|
|
full_rebuild = run_index(binary, env, repo_dir, args.timeout, args.include_logs)
|
|
|
|
incr_ms = max(1, int(incremental["elapsed_ms"]))
|
|
full_ms = max(1, int(full_rebuild["elapsed_ms"]))
|
|
speedup = full_ms / incr_ms
|
|
incremental_markers = incremental["markers"]
|
|
exact_marker = bool(incremental_markers["incremental_exact_done"])
|
|
defer_marker = bool(incremental_markers["pagerank_defer"])
|
|
passed = speedup >= args.min_speedup and exact_marker
|
|
|
|
report.update(
|
|
{
|
|
"changed_paths": changed_paths,
|
|
"removed_project_dbs": removed_dbs,
|
|
"measurements": {
|
|
"initial_fast_full": initial,
|
|
"incremental_exact": incremental,
|
|
"fresh_fast_full_after_change": full_rebuild,
|
|
},
|
|
"derived": {
|
|
"speedup_full_rebuild_over_incremental": speedup,
|
|
"exact_incremental_marker_seen": exact_marker,
|
|
"rank_defer_marker_seen": defer_marker,
|
|
"passed": passed,
|
|
},
|
|
}
|
|
)
|
|
exit_code = 0 if passed else 1
|
|
except Exception as exc:
|
|
report["error"] = f"{type(exc).__name__}: {exc}"
|
|
exit_code = 1
|
|
finally:
|
|
if auto_root and not args.keep_work_root:
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
report["cleanup"]["removed"] = not work_root.exists()
|
|
if args.out:
|
|
out_path = Path(args.out).expanduser()
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(json.dumps(report, indent=2, sort_keys=True))
|
|
|
|
return exit_code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|