mcp-calculator/docs/memory.md
2026-09-15 20:38:16 +02:00

8.6 KiB
Raw Blame History

Speicherbedarf

Analyse, woher der Speicherbedarf kommt, welche Optimierungen umgesetzt sind und wie man den Pod dimensioniert. Alle Werte sind RSS, gemessen unter macOS (arm64, Python 3.12). Unter Linux im Container liegen sie erfahrungsgemäß in derselben Größenordnung, messt dort bitte selbst nach:

python scripts/measure_memory.py imports            # Kosten pro Bibliothek
python scripts/measure_memory.py server             # kompletter Server, 2 Worker, nach Warm-up
python scripts/measure_memory.py server --workers 4

Kurzfassung

Konfiguration Server je Worker Resource-Tracker Summe
1 Worker 70 MB 137 MB 18 MB ~225 MB
2 Worker (Default) 70 MB 137 MB 18 MB ~360 MB
4 Worker 70 MB ~135 MB 18 MB ~630 MB

Faustformel: ~90 MB + Anzahl Worker × ~140 MB im Leerlauf. Dazu kommt Spielraum für laufende Berechnungen (siehe Dimensionierung).

Woher der Speicher kommt

Das Rechnen selbst braucht kaum Speicher. Den Großteil belegen die geladenen Bibliotheken, und zwar in jedem Prozess, der sie importiert. Kosten pro Import-Schritt (kumulativ, frischer Prozess):

Schritt RSS gesamt Zuwachs Wofür
Python-Interpreter 17 MB Basis
+ NumPy 28 MB +11 Numerik, Basis für SciPy/Pint
+ SymPy (inkl. mpmath) 62 MB +35 symbolische Mathematik, Parser
+ SciPy (scipy.stats) 123 MB +60 Statistik, Verteilungen, Tests; größter Einzelposten
+ Pint 126 MB +4 Einheiten
+ MCP SDK, Pydantic 161 MB +35 Protokoll, Schemas
+ uvicorn, Starlette, Pebble 162 MB +0 HTTP-Server, Pool (von MCP bereits geladen)

Dazu kommen zur Laufzeit ~10 MB für das Pint-Einheitenregister und die SymPy-Caches.

Prozessmodell

Server-Prozess          MCP-Protokoll, HTTP, Validierung   → braucht: MCP SDK, Pydantic, uvicorn
 ├─ Worker 1..N         die eigentlichen Berechnungen      → braucht: NumPy, SymPy, SciPy, Pint
 └─ Resource-Tracker    Hilfsprozess von multiprocessing   → ~18 MB, fix

Die Worker sind nötig, damit Berechnungen einen harten Timeout und ein Speicherlimit bekommen (siehe README, Abschnitt Sicherheit). Jeder Worker ist ein vollständiger Python-Prozess mit eigenem Speicher.

Umgesetzte Optimierungen

Vor der Optimierung luden alle Prozesse alle Bibliotheken (Server 167 MB, je Worker 162 MB, zusammen ~510 MB bei 2 Workern). Zwei Ursachen, zwei Maßnahmen:

1. Server-Prozess ohne Mathe-Bibliotheken (−97 MB)

Ursache: Der Server übergab den Workern Funktionsreferenzen (algebra.evaluate). Pickle braucht dafür das importierte Modul, also lud der Server SymPy und SciPy, ohne je damit zu rechnen. Zusätzlich kamen die Enum-Typen der Tool-Schemas aus den Rechenmodulen.

Maßnahme:

  • Aufträge gehen per Name an die Worker (executor.run("algebra.evaluate", …)). Der Worker löst den Namen selbst auf. Erlaubt sind nur öffentliche Funktionen der Module in executor.TASK_MODULES.
  • Alle Typen, Enums und Hilfetexte, die der Server braucht, liegen in ops/spec.py. Fehlertyp und Limits liegen in ops/context.py. Beide Module importieren keine Mathe-Bibliothek.

Ergebnis: Server 167 → 70 MB. Der Server-Prozess startet dadurch auch schneller (0,3 statt 0,8 s bis zum HTTP-Listen).

2. Worker ohne MCP/HTTP-Stack (−25 MB je Worker)

Ursache: Worker werden mit spawn gestartet. Dabei importiert Python in jedem Worker das Hauptmodul des Elternprozesses neu. Das war mcp_calculator.__main__, und das importierte auf oberster Ebene Server, MCP SDK und uvicorn.

Maßnahme: __main__.py importiert Server, HTTP-App und anyio erst innerhalb von main()/_serve().

Ergebnis: Worker 162 → 137 MB. Bei 2 Workern ~50 MB, bei 4 Workern ~100 MB weniger.

Absicherung

tests/test_footprint.py prüft das automatisch:

  • Server-, HTTP- und Executor-Module importieren weder SymPy, NumPy, SciPy, Pint noch mpmath.
  • Ein Worker, gestartet wie über das Konsolenskript, hat kein mcp, uvicorn und starlette geladen, dafür SymPy/SciPy vorgeladen, und läuft mit OPENBLAS_NUM_THREADS=1.
  • Das Speicherlimit wird relativ zur geladenen Größe berechnet, und ein Fehler beim Setzen legt den Worker nicht lahm.
  • Über den Task-Namen lassen sich nur öffentliche Funktionen der Rechenmodule aufrufen.

Wer später einen Import in server.py, http_app.py, executor.py oder __main__.py ergänzt, bekommt einen roten Test, falls der Import eine Mathe-Bibliothek in den Server oder den Server-Stack in die Worker zieht.

3. Vorgewärmte Worker

Pebble startet Worker erst beim ersten Auftrag. Der erste Tool-Aufruf hätte dann ~0,5 s auf das Laden von SymPy/SciPy gewartet. Der Pool bekommt deshalb beim Start einen leeren Auftrag, und /readyz meldet erst danach „bereit“ (~1,2 s nach Start). Der Pod bekommt also keinen Traffic, bevor er rechnen kann, und der erste echte Aufruf ist sofort schnell. Speicher kostet das nicht zusätzlich, die Worker wären ohnehin gestartet.

Bewusst nicht umgesetzt

Idee Warum nicht
SciPy erst beim ersten Statistik-Aufruf laden Worker werden wiederverwendet. Nach kurzer Zeit hat jeder Worker SciPy doch geladen, die Ersparnis wäre also nur vorübergehend. Außerdem würde der erste Statistik-Aufruf pro Worker ~0,5 s langsamer.
fork statt spawn (Copy-on-Write) Worker könnten die vom Parent geladenen Bibliotheken teilen. Forken eines Prozesses mit laufenden Threads (asyncio, uvicorn) ist aber unsicher und ab Python 3.14 nicht mehr Default. Copy-on-Write zerfällt in Python zudem schnell, weil der Referenzzähler Speicherseiten beschreibt.
forkserver mit vorgeladenen Modulen Theoretisch die Vorteile von fork ohne die Thread-Probleme. Mehr Komplexität, plattformabhängig (nicht unter macOS-Default), Nutzen wegen Copy-on-Write-Zerfall unsicher. Erst angehen, wenn viele Worker pro Pod nötig sind.
Worker ohne Pool (Berechnung im Server-Thread) Spart die Worker komplett, aber dann gibt es keinen harten Timeout und keine Isolation mehr. 9^9^9^9 würde den Server blockieren. Nur für Entwicklung (MCP_CALC_EXECUTOR=inline).

Dimensionierung

Worker-Anzahl. Ein Worker rechnet genau eine Anfrage gleichzeitig. MCP_CALC_WORKERS legt also die Parallelität fest. Weitere Anfragen warten in der Queue. Faustregel: Worker ≈ CPU-Limit des Pods. Mehr Worker als CPUs bringt bei CPU-lastigen Berechnungen nichts.

Speicher. Im Leerlauf gilt ~90 MB + Worker × ~140 MB. Unter Linux darf eine laufende Berechnung pro Worker zusätzlich höchstens MCP_CALC_MEMORY_LIMIT_MB belegen (Default 512 MB). Darüber wird sie mit MemoryError abgebrochen, der Worker bleibt stabil.

Technisch ist das ein RLIMIT_AS auf den virtuellen Adressraum. Er wird erst nach dem Laden der Bibliotheken gesetzt, als „aktueller Stand + Limit“. Hintergrund: NumPy/SciPy reservieren deutlich mehr virtuellen Speicher, als sie belegen (RSS). Ein absolutes Limit vor den Imports könnte schon den Import scheitern lassen. Weil virtueller Zuwachs ≥ echter Zuwachs ist, ist die Formel konservativ. Zusätzlich laufen BLAS/OpenMP pro Worker einthreadig (OPENBLAS_NUM_THREADS=1 usw., sofern nicht anders gesetzt). Das verhindert Thread-Puffer pro CPU-Kern und die Überbuchung des CPU-Limits.

Empfehlung für das Pod-Limit:

limit ≥ 90 MB + WORKERS × (140 MB + MEMORY_LIMIT_MB)
Profil WORKERS MEMORY_LIMIT_MB Pod-Limit Pod-Request
klein (Einzelteam) 1 512 ~768 Mi ~256 Mi
Standard (Defaults) 2 512 ~1.4 Gi ~400 Mi
großzügig 2 1024 ~2.4 Gi ~400 Mi
parallel 4 512 ~2.7 Gi ~700 Mi

Request ≈ Leerlaufbedarf, Limit ≈ Worst Case (alle Worker gleichzeitig am Speicherlimit). Wird das Pod-Limit kleiner gewählt, kann ein einzelner Ausreißer den ganzen Pod per OOM-Kill beenden, statt nur seine eigene Berechnung.

Ungetestet unter Linux: Das Speicherlimit und die Thread-Einstellungen greifen nur unter Linux und konnten hier (macOS) nicht im Betrieb geprüft werden, nur die Berechnung des Limits per Unit-Test. Bitte im Pod einmal prüfen: python scripts/measure_memory.py server sowie eine bewusst speicherhungrige Anfrage (z. B. evaluate mit factorial(10^7), die mit einer Memory- oder Timeout-Meldung enden sollte, ohne dass der Pod neu startet).

Alterung. SymPy baut interne Caches auf. Deshalb wird jeder Worker nach MCP_CALC_MAX_TASKS_PER_WORKER Aufgaben (Default 200) durch einen frischen ersetzt. Der Neustart kostet ~0,5 s in einem Worker, während die anderen weiterrechnen.