141 lines
5.3 KiB
Python
141 lines
5.3 KiB
Python
"""Measure the memory footprint of mcp-calculator (see docs/memory.md).
|
|
|
|
Usage (inside the project venv):
|
|
python scripts/measure_memory.py imports # RSS cost of each library, cumulative
|
|
python scripts/measure_memory.py server # start an HTTP server, warm it up, measure all processes
|
|
python scripts/measure_memory.py server --workers 4
|
|
|
|
Only the standard library is used, so it also runs in a container. RSS is read
|
|
via ``ps`` (macOS and Linux/procps).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
# Cumulative import steps in the order the server loads them.
|
|
IMPORT_STEPS = [
|
|
("Python interpreter", []),
|
|
("+ NumPy", ["numpy"]),
|
|
("+ SymPy", ["sympy"]),
|
|
("+ SciPy (scipy.stats)", ["scipy.stats"]),
|
|
("+ Pint", ["pint"]),
|
|
("+ MCP SDK, Pydantic", ["mcp.server.mcpserver"]),
|
|
("+ uvicorn, Starlette", ["uvicorn", "starlette"]),
|
|
("+ Pebble", ["pebble"]),
|
|
]
|
|
|
|
WARMUP_CALLS = [
|
|
("evaluate", {"expression": "sqrt(2)", "precision": 50}),
|
|
("solve", {"equations": ["x^2 + y^2 = 25", "x - y = 1"]}),
|
|
("calculus", {"operation": "integral", "expression": "x*e^x"}),
|
|
("analyze_function", {"expression": "x^3 - 3x"}),
|
|
("matrix", {"operation": "eigenvalues", "matrix": [[2, 1], [1, 2]]}),
|
|
("statistics", {"data": [1, 2, 3, 4, 5]}),
|
|
("probability", {"distribution": "normal", "operation": "cdf", "x": 1}),
|
|
("convert_units", {"quantity": "100 km/h", "to_unit": "m/s"}),
|
|
]
|
|
|
|
|
|
def rss_mb(pid: int) -> float:
|
|
return int(subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)])) / 1024
|
|
|
|
|
|
def measure_imports() -> None:
|
|
print(f"{'step':<28}{'RSS total':>10}{'delta':>8}")
|
|
previous = None
|
|
for label, _ in IMPORT_STEPS:
|
|
modules = [m for _, mods in IMPORT_STEPS[: [s[0] for s in IMPORT_STEPS].index(label) + 1] for m in mods]
|
|
code = "import importlib, os, subprocess\n"
|
|
code += "".join(f"importlib.import_module({m!r})\n" for m in modules)
|
|
code += "print(int(subprocess.check_output(['ps', '-o', 'rss=', '-p', str(os.getpid())])))"
|
|
total = int(subprocess.check_output([sys.executable, "-c", code])) / 1024
|
|
delta = "" if previous is None else f"+{total - previous:.0f}"
|
|
print(f"{label:<28}{total:>8.0f} MB{delta:>8}")
|
|
previous = total
|
|
|
|
|
|
def _children(pid: int) -> list[tuple[int, str]]:
|
|
out = subprocess.check_output(["ps", "-A", "-o", "pid=,ppid=,command="], text=True)
|
|
result = []
|
|
for line in out.splitlines():
|
|
parts = line.split(None, 2)
|
|
if len(parts) == 3 and int(parts[1]) == pid:
|
|
result.append((int(parts[0]), parts[2]))
|
|
return result
|
|
|
|
|
|
def _call(url: str, tool: str, arguments: dict) -> None:
|
|
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": arguments}}).encode()
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
"MCP-Protocol-Version": "2025-06-18",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=60) as response:
|
|
response.read()
|
|
|
|
|
|
def measure_server(workers: int) -> None:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
port = s.getsockname()[1]
|
|
env = {**os.environ, "MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": str(port), "MCP_CALC_WORKERS": str(workers), "MCP_CALC_LOG_LEVEL": "WARNING"}
|
|
proc = subprocess.Popen([sys.executable, "-m", "mcp_calculator"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
base = f"http://127.0.0.1:{port}"
|
|
try:
|
|
started = time.perf_counter()
|
|
while True:
|
|
try:
|
|
urllib.request.urlopen(f"{base}/readyz", timeout=1).read()
|
|
break
|
|
except OSError:
|
|
if time.perf_counter() - started > 60:
|
|
raise SystemExit("server did not become ready") from None
|
|
time.sleep(0.1)
|
|
ready_after = time.perf_counter() - started
|
|
|
|
first = time.perf_counter()
|
|
_call(f"{base}/mcp", "evaluate", {"expression": "1+1"})
|
|
first_call = time.perf_counter() - first
|
|
for _ in range(workers): # every worker should have computed something
|
|
for tool, arguments in WARMUP_CALLS:
|
|
_call(f"{base}/mcp", tool, arguments)
|
|
time.sleep(1)
|
|
|
|
rows = [("server", rss_mb(proc.pid))]
|
|
for pid, command in _children(proc.pid):
|
|
rows.append(("resource tracker" if "resource_tracker" in command else "worker", rss_mb(pid)))
|
|
print(f"ready after {ready_after:.2f}s, first call {first_call:.2f}s, {workers} worker(s)\n")
|
|
for name, mb in rows:
|
|
print(f"{name:<18}{mb:>6.0f} MB")
|
|
print(f"{'total':<18}{sum(mb for _, mb in rows):>6.0f} MB")
|
|
finally:
|
|
proc.terminate()
|
|
proc.wait(timeout=30)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("mode", choices=["imports", "server"])
|
|
parser.add_argument("--workers", type=int, default=2)
|
|
args = parser.parse_args()
|
|
if args.mode == "imports":
|
|
measure_imports()
|
|
else:
|
|
measure_server(args.workers)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|