mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Improve HTTP server startup performance (#4729)
This commit is contained in:
parent
c428a08fea
commit
34bdd480c9
15 changed files with 488 additions and 128 deletions
169
scripts/benchmark_http_startup.py
Normal file
169
scripts/benchmark_http_startup.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue