# 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**: ```bash 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](#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.