Phase 6.3: Parallelitaet und Determinismus - gemessen statt behauptet
Neuer Abschnitt im CP-SAT-Kapitel plus Parallele_Suche.py (76. Programm). Setzt Paket 2 aus Verbesserungen_02.md um. Was fehlte, war nicht die Regel, sondern die Messung: Das Buch setzt an acht Stellen num_workers = 1 mit dem Kommentar "fuer eine reproduzierbare Ausgabe", nachgeprueft hatte es nie jemand. Gerechnet wird auf demselben Job-Shop wie das Intervallvariablen-Kapitel, nur gross genug, dass die Suche arbeitet: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge. Zwei Befunde, beide staerker als die Behauptung: * Die Beschleunigung ist ueberlinear. Acht Arbeiter waren im abgedruckten Lauf nicht achtmal, sondern 12,3-mal schneller als einer. Kein Messfehler: CP-SAT vervielfacht nicht dieselbe Suche, sondern laesst verschiedene Strategien nebeneinander laufen, die einander ihre Schranken mitteilen. * Der Seed genuegt nicht - und zwar schon ab ZWEI Arbeitern. Ein Arbeiter: 1 Plan aus 4 Laeufen. Zwei Arbeiter: 3 verschiedene Plaene aus 4 Laeufen, bei identischem random_seed und identischem Zielwert 183. Die vollstaendige Antwort kam erst ueber die Uebungsaufgabe: Mit num_workers = 1, aber fuenf verschiedenen Seeds ergeben sich ebenfalls fuenf verschiedene Plaene. Keiner der beiden Parameter sichert die Reproduzierbarkeit allein - erst die Kombination traegt. Weiter fuer die Aufgabe gemessen: Der Gewinn kehrt sich um (auf 24 Kernen Faktor 10,2 bei 8 Arbeitern, 12,3 bei 16, 9,0 bei 24) - "so viele Arbeiter wie Kerne" ist damit widerlegt. Und bei 15 Auftraegen laeuft ein Arbeiter ins 60-s-Limit (FEASIBLE, Makespan 200), waehrend acht OPTIMAL mit demselben Makespan 200 nach 26,4 s melden: Der Unterschied liegt nicht in der Loesung, sondern im Beweis, dass es keine bessere gibt. Die abgedruckte Ausgabe traegt die Kennzeichnung "Laufzeiten und die Zahl der verschiedenen Plaene sind hardwareabhaengig" - nach dem Muster, das das Testing-Kapitel fuer Benchmark_Skalierung.py schon verwendet. Der Vergleich des extrahierten Programms mit dem Abdruck weicht denn auch in genau einer Zelle ab (4 statt 3 verschiedene Plaene bei 4 Arbeitern); Zielwert und Struktur sind identisch. Hier ist die Nichtreproduzierbarkeit der abgedruckten Zahl die Aussage selbst. Mitgezogen: Kapitelkopf, Lernziele, Selbsttest, Zusammenfassung, Vorwort-Programmverzeichnis, Uebungsaufgabe und Loesung in Anhang A, ein Verweis aus dem bestehenden Callout zu mehrdeutigen Optima und einer aus Warmstart_Effekt.py im MILP-Kapitel (dort nach Regel 12 der Kapitelname). Stand: 295 Abschnitte, 730 Querverweise, 328 Indexmarken, 76 Programme, 140 Aufgaben mit 140 Loesungen, 33 pytest-Tests, PDF 744 Seiten, 69 netzfreie Programme fehlerfrei. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
dd4acda129
commit
293370b7a1
30 changed files with 12643 additions and 10385 deletions
195
OR_HTML_04/programme/Parallele_Suche.py
Normal file
195
OR_HTML_04/programme/Parallele_Suche.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# Parallele_Suche.py
|
||||
"""
|
||||
Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.
|
||||
|
||||
Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte
|
||||
Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele
|
||||
Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute
|
||||
Loesungen findet. Beides wird hier gemessen statt geglaubt.
|
||||
|
||||
Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross
|
||||
genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,
|
||||
jeder Auftrag besucht jede Maschine.
|
||||
|
||||
Zwei Messungen:
|
||||
1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.
|
||||
2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt
|
||||
derselbe Plan heraus?
|
||||
|
||||
Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.
|
||||
|
||||
Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen
|
||||
Plaene ist es auch - und genau das ist die Aussage.
|
||||
|
||||
Benoetigt: numpy, ortools
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from ortools.sat.python import cp_model
|
||||
|
||||
AUFTRAEGE = 12
|
||||
MASCHINEN = 10
|
||||
INSTANZ_SEED = 20260908
|
||||
SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt
|
||||
ARBEITERZAHLEN = (1, 2, 4, 8)
|
||||
WIEDERHOLUNGEN = 4
|
||||
ZEITLIMIT = 120.0
|
||||
|
||||
|
||||
def baue_instanz() -> list[list[tuple[int, int]]]:
|
||||
"""Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,
|
||||
in einer eigenen zufaelligen Reihenfolge."""
|
||||
rng = np.random.default_rng(INSTANZ_SEED)
|
||||
return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]
|
||||
for _ in range(AUFTRAEGE)]
|
||||
|
||||
|
||||
def loese(auftraege, arbeiter: int):
|
||||
"""Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan
|
||||
zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe
|
||||
vergleichen lassen."""
|
||||
horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)
|
||||
modell = cp_model.CpModel()
|
||||
Gang = collections.namedtuple("Gang", "start ende intervall")
|
||||
plaene: dict[tuple[int, int], Gang] = {}
|
||||
je_maschine = collections.defaultdict(list)
|
||||
|
||||
for a, auftrag in enumerate(auftraege):
|
||||
for g, (maschine, dauer) in enumerate(auftrag):
|
||||
start = modell.NewIntVar(0, horizont, f"start_{a}_{g}")
|
||||
ende = modell.NewIntVar(0, horizont, f"ende_{a}_{g}")
|
||||
intervall = modell.NewIntervalVar(start, dauer, ende, f"iv_{a}_{g}")
|
||||
plaene[a, g] = Gang(start, ende, intervall)
|
||||
je_maschine[maschine].append(intervall)
|
||||
|
||||
for maschine in range(MASCHINEN):
|
||||
modell.AddNoOverlap(je_maschine[maschine])
|
||||
for a, auftrag in enumerate(auftraege):
|
||||
for g in range(len(auftrag) - 1):
|
||||
modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)
|
||||
|
||||
makespan = modell.NewIntVar(0, horizont, "makespan")
|
||||
modell.AddMaxEquality(
|
||||
makespan, [plaene[a, len(auftrag) - 1].ende
|
||||
for a, auftrag in enumerate(auftraege)])
|
||||
modell.Minimize(makespan)
|
||||
|
||||
loeser = cp_model.CpSolver()
|
||||
loeser.parameters.num_workers = arbeiter
|
||||
loeser.parameters.random_seed = SOLVER_SEED
|
||||
loeser.parameters.max_time_in_seconds = ZEITLIMIT
|
||||
|
||||
beginn = time.perf_counter()
|
||||
status = loeser.Solve(modell)
|
||||
dauer = time.perf_counter() - beginn
|
||||
|
||||
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
|
||||
return loeser.StatusName(status), None, dauer, None
|
||||
plan = tuple(loeser.Value(plaene[a, g].start)
|
||||
for a, auftrag in enumerate(auftraege)
|
||||
for g in range(len(auftrag)))
|
||||
return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
auftraege = baue_instanz()
|
||||
gaenge = sum(len(a) for a in auftraege)
|
||||
|
||||
print("=" * 78)
|
||||
print(" WAS MEHRERE ARBEITER WIRKLICH BRINGEN")
|
||||
print("=" * 78)
|
||||
print(f"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, "
|
||||
f"{gaenge} Arbeitsgaenge.")
|
||||
print(f"random_seed = {SOLVER_SEED} in JEDEM Lauf, "
|
||||
f"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\n")
|
||||
|
||||
# Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.
|
||||
ergebnisse: dict[int, list[tuple]] = {}
|
||||
for arbeiter in ARBEITERZAHLEN:
|
||||
ergebnisse[arbeiter] = [loese(auftraege, arbeiter)
|
||||
for _ in range(WIEDERHOLUNGEN)]
|
||||
|
||||
print("=" * 78)
|
||||
print(" (1) Laufzeit: was die Arbeiter bringen")
|
||||
print("=" * 78)
|
||||
print(f"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} "
|
||||
f"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}")
|
||||
print("-" * 78)
|
||||
basis_zeit = min(d for _, _, d, _ in ergebnisse[1])
|
||||
for arbeiter in ARBEITERZAHLEN:
|
||||
laeufe = ergebnisse[arbeiter]
|
||||
zeiten = [d for _, _, d, _ in laeufe]
|
||||
ziele = {o for _, o, _, _ in laeufe}
|
||||
status = {s for s, _, _, _ in laeufe}
|
||||
print(f"{arbeiter:>9} {'/'.join(sorted(status)):>9} "
|
||||
f"{'/'.join(str(z) for z in sorted(ziele)):>9} "
|
||||
f"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s "
|
||||
f"{basis_zeit / min(zeiten):>14.1f}x")
|
||||
|
||||
beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))
|
||||
faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])
|
||||
print(f"\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber")
|
||||
print(f" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.")
|
||||
print(f" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche")
|
||||
print(" laufen, sondern verschiedene Strategien nebeneinander, die einander")
|
||||
print(" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der")
|
||||
print(" 'zweite Kern', sondern ein anderes Verfahren.")
|
||||
letzte = ARBEITERZAHLEN[-1]
|
||||
if beste != letzte:
|
||||
zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])
|
||||
print(f"\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen "
|
||||
f"{zeit_letzte:.2f}s")
|
||||
print(f" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei "
|
||||
f"{beste}. Ab einer gewissen Zahl kosten Abstimmung")
|
||||
print(" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.")
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print(" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?")
|
||||
print("=" * 78)
|
||||
print(f"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} "
|
||||
f"{'Makespan':>10}")
|
||||
print("-" * 78)
|
||||
for arbeiter in ARBEITERZAHLEN:
|
||||
plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}
|
||||
ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}
|
||||
print(f"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} "
|
||||
f"{'/'.join(str(z) for z in sorted(ziele)):>10}")
|
||||
|
||||
einer = len({p for _, _, _, p in ergebnisse[1]})
|
||||
# Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die
|
||||
# eigentliche Zahl - nicht, was acht Arbeiter anrichten.
|
||||
ab = next((w for w in ARBEITERZAHLEN
|
||||
if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)
|
||||
print(f"\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.")
|
||||
if ab is not None:
|
||||
viele = len({p for _, _, _, p in ergebnisse[ab]})
|
||||
print(f" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus "
|
||||
f"{WIEDERHOLUNGEN} Laeufen -")
|
||||
print(" bei identischem random_seed und identischem Zielwert.")
|
||||
print(f" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit")
|
||||
print(f" zu verlieren. {ab} genuegen.")
|
||||
print("\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt")
|
||||
print(" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von")
|
||||
print(" mehreren zuerst fertig wird. Das entscheidet die Uhr.")
|
||||
|
||||
print("\n" + "=" * 78)
|
||||
print(" (3) Was daraus fuer Tests folgt")
|
||||
print("=" * 78)
|
||||
print(" Ein Test der Form assert plan == erwarteter_plan besteht mal und")
|
||||
print(" scheitert mal, ohne dass sich am Code etwas geaendert haette.")
|
||||
print(" Zu pruefen sind stattdessen:")
|
||||
print(" * der ZIELWERT (hier in allen Laeufen gleich),")
|
||||
print(" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),")
|
||||
print(" * und der Status - nicht die Gestalt der Loesung.")
|
||||
print("\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte")
|
||||
print(" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.")
|
||||
print(f" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau")
|
||||
print(" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.")
|
||||
print("=" * 78)
|
||||
Loading…
Reference in a new issue