operations_research/OR_HTML_04/programme/Benchmark_Skalierung.py
dschlueter 862e92bc7b Phase 8.2: Solver-Isolation ohne subprocess-Codestrings
Setzt den Isolationsteil von Paket 1 aus Verbesserungen_02.md um. Der Plan
nannte zwei Programme; beim Suchen kam ein drittes dazu, das dasselbe Muster
verwendete.

Ein_System_Vier_Ansaetze.py und Benchmark_Skalierung.py hielten ihre vier
Solvervarianten als Zeichenketten in einem Dictionary und gaben sie an
"python -c" weiter - bei Benchmark_Skalierung.py sogar mit
.format()-Platzhaltern fuer die Instanzgroesse. Aus jeder Variante ist jetzt
eine gewoehnliche Funktion mit lokalem Import geworden.
Solverwechsel_CPSAT_HiGHS.py rief sich selbst ueber sys.argv erneut auf;
auch das entfaellt.

Ausgefuehrt wird ueber einen ProcessPoolExecutor mit zwei Einstellungen, die
beide noetig sind: mp_context "spawn" (frischer Interpreter statt geerbtem
Speicher - unter Linux ist fork der Standard) und max_tasks_per_child=1 (ein
neuer Prozess je Aufgabe; ohne das verwendet der Pool seinen Arbeiter
wieder, und beim zweiten Solver ist der Konflikt zurueck). Nachgemessen:
vier Aufgaben, vier verschiedene PIDs.

Der zweite Punkt hat einen eigenen Warnkasten bekommen, weil der Fehler
leicht zu machen und schwer zu finden ist: Der Absturz kaeme nicht beim
ersten Solver, sondern beim zweiten - und saehe aus wie ein Problem des
zweiten.

Regel 4, dreifach geprueft. Ein_System_Vier_Ansaetze.py: identisch bis auf
die Zeitspalte, einschliesslich der Spannweite 2,41e-08, auf die sich der
Merksatz des Kapitels beruft. Benchmark_Skalierung.py: alle zwoelf
Zielwerte und alle drei Spannweiten bitgleich; Zeiten und Speicher haben
sich verschoben, beide sind im Abdruck seit jeher als hardwareabhaengig
gekennzeichnet. Solverwechsel_CPSAT_HiGHS.py: Ausgabe ohne Zeiten
unveraendert.

Bewusst subprocess bleibt Mutationstest.py: Dort wird pytest auf einer
mutierten Kopie in einem temporaeren Verzeichnis gestartet - ein externes
Werkzeug auf veraenderten Dateien, nicht die Isolation eines Imports.

Neu im Kapitel Oekosystem: ein Abschnitt "Wie die Isolation aussieht, wenn
sie tragen soll" - warum ein Codestring die schlechteste Umsetzung von
"eigener Prozess" ist. Anhang C nennt jetzt ebenfalls ProcessPoolExecutor.

Ein eigener Fehler, gefunden und abgesichert: Ich hatte dem neuen ### ein
{#sec:...}-Label gegeben. ABSCHNITT_RE erkennt nur "## " - das Label waere
nie registriert worden und jeder Verweis darauf ins Leere gelaufen, ohne
Warnung. Label entfernt, --check meldet den Fall jetzt. Gegengetestet.

Stand: 818 Querverweise, 76 Programme, 33 pytest-Tests, PDF 760 Seiten, 69
netzfreie Programme fehlerfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:39:34 +02:00

213 lines
9 KiB
Python

#!/usr/bin/env python3
# Benchmark_Skalierung.py
"""
Kapitel Testen: Ein Vergleich, dem man glauben kann.
Solververgleiche stehen in jedem Blog, und die meisten sind wertlos - nicht
weil falsch gemessen wurde, sondern weil zu wenig dazugesagt wird. Dieses
Programm misst dasselbe Transportproblem in drei Groessen mit vier
Bibliotheken und haelt sich dabei an fuenf Regeln, die den Unterschied machen:
1. EIGENER PROZESS je Bibliothek. Nicht nur wegen des Importkonflikts
zwischen ortools und highspy (Kapitel Oekosystem) - auch, damit der
Speicherverbrauch der einen nicht in der Messung der anderen auftaucht.
2. AUFBAU UND LOESEN GETRENNT messen. Bei grossen Instanzen ist der Aufbau
des Modells in Python regelmaessig teurer als das Loesen. Wer nur die
Gesamtzeit misst, optimiert am Ende die falsche Haelfte.
3. ZIELWERTE GEGENEINANDER PRUEFEN. Eine Bibliothek, die schneller ist und
etwas anderes ausrechnet, hat den Vergleich nicht gewonnen. Diese Pruefung
ist der wichtigste Teil des Programms.
4. SPEICHER MITMESSEN. Bei 10.000 Variablen entscheidet oft er und nicht die
Zeit darueber, was auf einer Maschine noch laeuft.
5. DIESELBE INSTANZ fuer alle. Feste Saat, kein Neuwuerfeln zwischendurch.
Und die Einschraenkung, die dazugehoert: Gemessen wird EIN Problemtyp in EINER
Formulierung auf EINER Maschine. Das Ergebnis ist keine Rangliste der Solver,
sondern eine Entscheidungshilfe fuer genau diesen Fall. Wer es verallgemeinert,
macht denselben Fehler wie jemand, der aus einem Backtest auf die Zukunft
schliesst (Kapitel Handelsmaschine).
Benoetigt: numpy; in den Kindprozessen scipy, highspy, ortools, cvxpy
"""
from __future__ import annotations
import multiprocessing
import resource
import time
from concurrent.futures import ProcessPoolExecutor
import numpy as np
GROESSEN = [(10, 10), (32, 32), (100, 100)] # (Lager, Kunden) -> 100 / 1.024 / 10.000 Variablen
# Instanz und Speichermessung stehen als gewoehnliche Funktionen hier - nicht
# in einem String, den ein Kindprozess ausfuehrt. Jede Messfunktion baut die
# Instanz aus derselben Saat neu auf, damit ueber die Prozessgrenze nichts
# reist, was das Ergebnis verfaelschen koennte.
def instanz(m: int, n: int):
rng = np.random.default_rng(20)
kosten = rng.integers(5, 95, (m, n)).astype(float)
angebot = rng.integers(50, 150, m).astype(float)
bedarf = angebot.sum() * rng.dirichlet(np.ones(n))
return kosten, angebot, bedarf
def speicher_mb() -> float:
# ru_maxrss ist unter Linux in Kilobyte. Gemessen wird der Kindprozess -
# deshalb muss jede Messung einen eigenen bekommen.
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
def messe_scipy(m: int, n: int):
from scipy.optimize import linprog
kosten, angebot, bedarf = instanz(m, n)
t0 = time.perf_counter()
c = kosten.reshape(-1)
A_ub = np.zeros((m, m * n)); A_eq = np.zeros((n, m * n))
for i in range(m):
A_ub[i, i * n:(i + 1) * n] = 1.0
for j in range(n):
A_eq[j, j::n] = 1.0
aufbau = time.perf_counter() - t0
t0 = time.perf_counter()
r = linprog(c=c, A_ub=A_ub, b_ub=angebot, A_eq=A_eq, b_eq=bedarf,
bounds=(0, None), method="highs")
loesen = time.perf_counter() - t0
return float(r.fun), aufbau, loesen, speicher_mb()
def messe_highspy(m: int, n: int):
import highspy
kosten, angebot, bedarf = instanz(m, n)
t0 = time.perf_counter()
h = highspy.Highs(); h.setOptionValue("output_flag", False)
h.addVars(m * n, np.zeros(m * n), np.full(m * n, highspy.kHighsInf))
for k in range(m * n):
h.changeColCost(k, float(kosten.reshape(-1)[k]))
for i in range(m):
idx = np.arange(i * n, (i + 1) * n, dtype=np.int32)
h.addRow(-highspy.kHighsInf, float(angebot[i]), n, idx, np.ones(n))
for j in range(n):
idx = np.arange(j, m * n, n, dtype=np.int32)
h.addRow(float(bedarf[j]), float(bedarf[j]), m, idx, np.ones(m))
aufbau = time.perf_counter() - t0
t0 = time.perf_counter(); h.run(); loesen = time.perf_counter() - t0
return h.getInfo().objective_function_value, aufbau, loesen, speicher_mb()
def messe_ortools(m: int, n: int):
from ortools.linear_solver import pywraplp
kosten, angebot, bedarf = instanz(m, n)
t0 = time.perf_counter()
s = pywraplp.Solver.CreateSolver("GLOP")
x = [[s.NumVar(0, s.infinity(), f"x{i}_{j}") for j in range(n)]
for i in range(m)]
for i in range(m):
s.Add(sum(x[i]) <= float(angebot[i]))
for j in range(n):
s.Add(sum(x[i][j] for i in range(m)) == float(bedarf[j]))
s.Minimize(sum(float(kosten[i, j]) * x[i][j]
for i in range(m) for j in range(n)))
aufbau = time.perf_counter() - t0
t0 = time.perf_counter(); s.Solve(); loesen = time.perf_counter() - t0
return s.Objective().Value(), aufbau, loesen, speicher_mb()
def messe_cvxpy(m: int, n: int):
import cvxpy as cp
kosten, angebot, bedarf = instanz(m, n)
t0 = time.perf_counter()
x = cp.Variable((m, n), nonneg=True)
problem = cp.Problem(cp.Minimize(cp.sum(cp.multiply(kosten, x))),
[cp.sum(x, axis=1) <= angebot,
cp.sum(x, axis=0) == bedarf])
aufbau = time.perf_counter() - t0
t0 = time.perf_counter(); problem.solve(); loesen = time.perf_counter() - t0
return float(problem.value), aufbau, loesen, speicher_mb()
ANSAETZE = {"scipy.linprog": messe_scipy, "highspy": messe_highspy,
"ortools/GLOP": messe_ortools, "cvxpy": messe_cvxpy}
def messe(funktion, m: int, n: int):
"""Fuehrt eine Messfunktion in einem FRISCHEN Prozess aus.
'spawn' und max_tasks_per_child=1 zusammen garantieren, was Regel 1
verlangt: Jede Messung sieht einen leeren Interpreter. Ohne das
zweite wuerde der Pool seinen Arbeiter wiederverwenden - dann waere
der Speicherwert der zweiten Bibliothek um die erste zu hoch, und
ortools und highspy saessen im selben Prozess.
"""
with ProcessPoolExecutor(
max_workers=1,
mp_context=multiprocessing.get_context("spawn"),
max_tasks_per_child=1) as pool:
try:
return pool.submit(funktion, m, n).result(timeout=600), None
except Exception as fehler:
return None, str(fehler).strip().splitlines()[-1][:60]
if __name__ == "__main__":
print("=" * 92)
print(" SKALIERUNGSVERGLEICH: TRANSPORTPROBLEM, VIER BIBLIOTHEKEN")
print("=" * 92)
print("Jede Zeile ein eigener Prozess. Zeiten und Speicher sind "
"hardwareabhaengig,")
print("die Zielwerte und ihr Verhaeltnis zueinander nicht.\n")
for m, n in GROESSEN:
kopf = f"--- {m} Lager x {n} Kunden = {m * n:,} Variablen "
print(kopf + "-" * max(3, 92 - len(kopf)))
print(f" {'Bibliothek':<16} {'Zielwert':>14} {'Aufbau':>9} "
f"{'Loesen':>9} {'Anteil':>8} {'Speicher':>10}")
print(" " + "-" * 72)
zielwerte = {}
for name, funktion in ANSAETZE.items():
werte, fehler = messe(funktion, m, n)
if werte is None:
print(f" {name:<16} nicht verfuegbar: {fehler}")
continue
ziel, aufbau, loesen, speicher = werte
zielwerte[name] = ziel
anteil = aufbau / (aufbau + loesen) * 100
print(f" {name:<16} {ziel:>14,.2f} {aufbau:>8.3f}s "
f"{loesen:>8.3f}s {anteil:>7.0f}% {speicher:>9.0f} MB")
# Die wichtigste Zeile: Rechnen alle dasselbe aus?
spanne = max(zielwerte.values()) - min(zielwerte.values())
bezug = max(abs(v) for v in zielwerte.values())
print(f" {'':16} Spannweite der Zielwerte: {spanne:.2e} "
f"(relativ {spanne / bezug:.1e})")
if spanne / bezug > 1e-6:
print(" ACHTUNG: Die Bibliotheken widersprechen sich - "
"der Zeitvergleich ist wertlos.")
print()
print("=" * 92)
print(" WAS MAN AUS SO EINER TABELLE ABLESEN DARF - UND WAS NICHT")
print("=" * 92)
print("DARF man ablesen:")
print(" * Die Spalte 'Anteil' - wie viel der Zeit in den AUFBAU geht statt")
print(" ins Loesen. Wenn dort 80 % stehen, ist ein schnellerer Solver die")
print(" falsche Antwort; dann gehoert das Modell vektorisiert aufgebaut")
print(" (Kapitel Oekosystem).")
print(" * Die Groessenordnung des Speicherbedarfs. Sie entscheidet, was auf")
print(" einer bestimmten Maschine ueberhaupt laeuft.")
print(" * Wie sich beides mit der Groesse ENTWICKELT. Der Trend ist")
print(" uebertragbarer als der Absolutwert.")
print()
print("NICHT ablesen darf man:")
print(" * 'Bibliothek X ist schneller als Y.' Gemessen wurde EIN")
print(" Problemtyp in EINER Formulierung. Ein MILP, ein QP oder eine")
print(" andere Modellierung desselben Problems koennen die Reihenfolge")
print(" umdrehen.")
print(" * Etwas ueber Ihre Maschine. Diese Zahlen stammen von einer")
print(" anderen. Der Sinn des Programms ist, dass Sie es auf Ihrer")
print(" laufen lassen.")
print("=" * 92)