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

154 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.