#!/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)