154 lines
8.6 KiB
Markdown
154 lines
8.6 KiB
Markdown
# 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.
|