mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
169 lines
4.7 KiB
Python
169 lines
4.7 KiB
Python
#!/usr/bin/env python
|
|
"""Benchmark FastMCP's HTTP server cold-start path in fresh interpreters.
|
|
|
|
The benchmark separates the work users pay before an HTTP server can accept
|
|
requests:
|
|
|
|
1. import the public ``FastMCP`` entry point;
|
|
2. construct a server and register representative tools;
|
|
3. build the Streamable HTTP ASGI application.
|
|
|
|
Every sample runs in a fresh interpreter. Use ratios and the shape of the
|
|
results rather than treating single-machine absolute timings as universal.
|
|
|
|
Usage:
|
|
uv run python scripts/benchmark_http_startup.py
|
|
uv run python scripts/benchmark_http_startup.py --runs 10
|
|
uv run python scripts/benchmark_http_startup.py --json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from collections.abc import Sequence
|
|
from typing import TypedDict
|
|
|
|
|
|
class Sample(TypedDict):
|
|
import_ms: float
|
|
server_ms: float
|
|
app_ms: float
|
|
total_ms: float
|
|
module_count: int
|
|
rss_mib: float
|
|
heavy_module_counts: dict[str, int]
|
|
|
|
|
|
_PROBE = textwrap.dedent(
|
|
"""
|
|
import json
|
|
import resource
|
|
import sys
|
|
import time
|
|
|
|
started = time.perf_counter()
|
|
from fastmcp import FastMCP
|
|
imported = time.perf_counter()
|
|
|
|
server = FastMCP("HTTP cold-start benchmark")
|
|
|
|
def make_tool(index):
|
|
def tool(value: int = index) -> int:
|
|
return value
|
|
|
|
tool.__name__ = f"tool_{index}"
|
|
return tool
|
|
|
|
for index in range(10):
|
|
server.tool(make_tool(index))
|
|
configured = time.perf_counter()
|
|
|
|
app = server.http_app(transport="http", stateless_http=True)
|
|
assert app is not None
|
|
ready = time.perf_counter()
|
|
|
|
heavy_roots = {
|
|
"authlib",
|
|
"cryptography",
|
|
"httpx2",
|
|
"key_value",
|
|
"mcp",
|
|
"mcp_types",
|
|
"opentelemetry",
|
|
"pydantic",
|
|
"rich",
|
|
"sse_starlette",
|
|
"starlette",
|
|
"uvicorn",
|
|
}
|
|
heavy_module_counts = {
|
|
root: sum(
|
|
module == root or module.startswith(f"{root}.") for module in sys.modules
|
|
)
|
|
for root in sorted(heavy_roots)
|
|
}
|
|
heavy_module_counts = {
|
|
root: count for root, count in heavy_module_counts.items() if count
|
|
}
|
|
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"import_ms": (imported - started) * 1000,
|
|
"server_ms": (configured - imported) * 1000,
|
|
"app_ms": (ready - configured) * 1000,
|
|
"total_ms": (ready - started) * 1000,
|
|
"module_count": len(sys.modules),
|
|
"rss_mib": (
|
|
resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
/ (1024 * 1024)
|
|
if sys.platform == "darwin"
|
|
else resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
|
),
|
|
"heavy_module_counts": heavy_module_counts,
|
|
}
|
|
)
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def _sample() -> Sample:
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", _PROBE],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr)
|
|
return json.loads(result.stdout.strip().splitlines()[-1])
|
|
|
|
|
|
def _median(samples: Sequence[Sample], key: str) -> float:
|
|
return statistics.median(float(sample[key]) for sample in samples) # type: ignore[literal-required]
|
|
|
|
|
|
def _summarize(samples: list[Sample]) -> dict[str, object]:
|
|
return {
|
|
"runs": len(samples),
|
|
"import_ms": round(_median(samples, "import_ms"), 1),
|
|
"server_ms": round(_median(samples, "server_ms"), 1),
|
|
"app_ms": round(_median(samples, "app_ms"), 1),
|
|
"total_ms": round(_median(samples, "total_ms"), 1),
|
|
"module_count": round(_median(samples, "module_count")),
|
|
"rss_mib": round(_median(samples, "rss_mib"), 1),
|
|
"heavy_module_counts": samples[-1]["heavy_module_counts"],
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--runs", type=int, default=5)
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
samples = [_sample() for _ in range(args.runs)]
|
|
summary = _summarize(samples)
|
|
if args.json:
|
|
print(json.dumps(summary, indent=2))
|
|
return
|
|
|
|
print(f"Python: {sys.version.split()[0]}")
|
|
print(f"Runs: {summary['runs']}")
|
|
print(f"Import FastMCP: {summary['import_ms']:.1f} ms")
|
|
print(f"Construct + 10 tools: {summary['server_ms']:.1f} ms")
|
|
print(f"Build HTTP app: {summary['app_ms']:.1f} ms")
|
|
print(f"Total to ASGI app: {summary['total_ms']:.1f} ms")
|
|
print(f"Modules: {summary['module_count']}")
|
|
print(f"Peak RSS: {summary['rss_mib']:.1f} MiB")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|