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>
195 lines
8.5 KiB
Python
Generated
195 lines
8.5 KiB
Python
Generated
#!/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)
|