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:
dschlueter 2026-09-08 11:19:53 +02:00
commit 293370b7a1
30 changed files with 12643 additions and 10385 deletions

View 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)

View file

@ -83,7 +83,8 @@ def loese(dauer: np.ndarray, n_maschinen: int, zeitlimit: float,
loeser = cp_model.CpSolver()
loeser.parameters.max_time_in_seconds = zeitlimit
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.
# Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -
# der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).
# Im Produktivbetrieb laesst man beides auf den Standardwerten.
loeser.parameters.num_workers = 1
loeser.parameters.random_seed = 1