8.6 KiB
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 inexecutor.TASK_MODULES. - Alle Typen, Enums und Hilfetexte, die der Server braucht, liegen in
ops/spec.py. Fehlertyp und Limits liegen inops/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,uvicornundstarlettegeladen, dafür SymPy/SciPy vorgeladen, und läuft mitOPENBLAS_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 serversowie eine bewusst speicherhungrige Anfrage (z. B.evaluatemitfactorial(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.