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

@ -431,6 +431,7 @@ Pfad E oder um gezielt nach einem Thema zu suchen:
| `Propagation_Demo.py` | Propagation messbar | {ref:kap:cpsat} |
| `CP_SAT_Vertretungssystem.py` | Vertretungsplan | {ref:kap:cpsat} |
| `JobShop_Intervalle.py` | Job-Shop-Scheduling | {ref:kap:cpsat} |
| `Parallele_Suche.py` | num_workers: Tempo gegen Reproduzierbarkeit | {ref:kap:cpsat} |
| `CP_SAT_Statusfaelle.py` | Die fünf Solver-Antworten | {ref:kap:cpsat} |
| `Strafgewichte.py` | Gewichte als Wechselkurse | {ref:kap:cpsat} |
| `Min_Cost_Flow.py` | Netzwerkfluss | {ref:kap:graphen} |

View file

@ -1308,7 +1308,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

View file

@ -18,6 +18,7 @@
> `Propagation_Demo.py`\
> `CP_SAT_Vertretungssystem.py`\
> `JobShop_Intervalle.py`\
> `Parallele_Suche.py`\
> `CP_SAT_Statusfaelle.py`\
> `Strafgewichte.py`
@ -101,7 +102,9 @@ Nach diesem Kapitel können Sie …
6. … die fünf CP-SAT-Statusfälle unterscheiden — insbesondere `INFEASIBLE` (es gibt keine
Lösung) von `UNKNOWN` (es wurde keine gefunden) und `MODEL_INVALID` (Ihr Code ist
fehlerhaft).
7. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb
7. … begründen, warum `num_workers = 1` für einen reproduzierbaren Lauf nötig ist und ein
fester `random_seed` allein nicht genügt — und was ein Test deshalb prüfen darf.
8. … begründen, warum ein Strafgewicht ein **Wechselkurs** ist, und welche Regeln deshalb
niemals in die Zielfunktion gehören.
---
@ -794,6 +797,9 @@ Zusammenspiel von Reihenfolge- und Maschinenbedingungen, nicht aus einer schlech
> abgedruckte Ausgabe reproduzierbar. **Im Produktivbetrieb lässt man beides weg** — mehrere
> Arbeiter sind deutlich schneller.
>
> Wie viel schneller, und warum der Seed allein **nicht** genügt, misst
> {ref:sec:cpsat-parallele-suche} an genau diesem Modell.
>
> Die Lehre für Ihre eigenen Tests: Prüfen Sie bei mehrdeutigen Optima nie auf einen
> **bestimmten Plan**, sondern immer auf den **Zielwert** und auf die Einhaltung aller
> Regeln. Ein Test wie `assert plan == erwarteter_plan` besteht sonst mal und scheitert mal,
@ -817,6 +823,336 @@ theoretischen Ideal entfernt sein können, noch bevor der Solver fertig war.
---
## Parallele Suche: was `num_workers` wirklich bewirkt {#sec:cpsat-parallele-suche}
An mehreren Stellen dieses Buchs steht `loeser.parameters.num_workers = 1`, mit dem
Kommentar „für eine reproduzierbare Ausgabe“. Darin stecken zwei Behauptungen: dass
parallele Suche **schneller** ist, und dass sie **von Lauf zu Lauf andere, gleich gute**
Lösungen liefert. Beides lässt sich messen — und beim Messen kommt mehr heraus als erwartet.
Gerechnet wird auf demselben Modell wie
{ref:sec:cpsat-intervallvariablen-job-shop-scheduling}, nur groß genug, dass die Suche
wirklich arbeitet: 12 Aufträge auf 10 Maschinen, jeder Auftrag besucht jede Maschine.
Entscheidend ist, dass `random_seed` in **jedem** Lauf denselben Wert hat.
```python
#!/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)
```
**Erwartete Ausgabe** (Laufzeiten *und* die Zahl der verschiedenen Pläne sind
hardwareabhängig — genau das ist die Aussage; der Zielwert ist es nicht):
```
==============================================================================
WAS MEHRERE ARBEITER WIRKLICH BRINGEN
==============================================================================
Job-Shop: 12 Auftraege, 10 Maschinen, 120 Arbeitsgaenge.
random_seed = 1 in JEDEM Lauf, 4 Wiederholungen je Arbeiterzahl.
==============================================================================
(1) Laufzeit: was die Arbeiter bringen
==============================================================================
Arbeiter Status Makespan schnellste langsamste Beschleunigung
------------------------------------------------------------------------------
1 OPTIMAL 183 5.62s 5.82s 1.0x
2 OPTIMAL 183 1.43s 2.04s 3.9x
4 OPTIMAL 183 0.60s 0.66s 9.4x
8 OPTIMAL 183 0.46s 0.65s 12.3x
Am schnellsten sind 8 Arbeiter: Faktor 12.3 gegenueber
einem einzigen - also MEHR, als 8 Kerne hergeben sollten.
Das ist kein Messfehler. CP-SAT laesst nicht 8-mal dieselbe Suche
laufen, sondern verschiedene Strategien nebeneinander, die einander
ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der
'zweite Kern', sondern ein anderes Verfahren.
==============================================================================
(2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?
==============================================================================
Arbeiter Laeufe verschiedene Plaene Makespan
------------------------------------------------------------------------------
1 4 1 183
2 4 3 183
4 4 3 183
8 4 4 183
Mit einem Arbeiter: 1 Plan aus 4 Laeufen.
Schon mit 2 Arbeitern: 3 verschiedene Plaene aus 4 Laeufen -
bei identischem random_seed und identischem Zielwert.
Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit
zu verlieren. 2 genuegen.
Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt
fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von
mehreren zuerst fertig wird. Das entscheidet die Uhr.
==============================================================================
(3) Was daraus fuer Tests folgt
==============================================================================
Ein Test der Form assert plan == erwarteter_plan besteht mal und
scheitert mal, ohne dass sich am Code etwas geaendert haette.
Zu pruefen sind stattdessen:
* der ZIELWERT (hier in allen Laeufen gleich),
* die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),
* und der Status - nicht die Gestalt der Loesung.
Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte
Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.
Das kostet hier den Faktor 12.3 an Laufzeit und ist genau
deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.
==============================================================================
```
### Was die Messung zeigt
**Erstens: Die Beschleunigung ist überlinear.** Acht Arbeiter sind im abgedruckten Lauf
nicht achtmal, sondern **gut zwölfmal** schneller als einer. Das sieht nach Messfehler aus, ist aber die
korrekte Beschreibung dessen, was CP-SAT tut: Es lässt **nicht** achtmal dieselbe Suche auf
verschiedenen Kernen laufen, sondern **verschiedene Strategien nebeneinander** — Suche mit
LP-Relaxation, Suche mit anderen Verzweigungsregeln, lokale Suche —, und die teilen
einander ihre Schranken mit. Der zweite Arbeiter ist deshalb nicht „der zweite Kern“,
sondern ein anderes Verfahren, das zufällig auch rechnet.
Daraus folgt zweierlei. Ein Speedup über der Kernzahl ist bei CP-SAT normal und kein Grund
zum Misstrauen. Und der Zugewinn flacht ab: Zwischen vier und acht Arbeitern lag in unseren
Wiederholungen mal das eine, mal das andere vorn — ab einer gewissen Zahl kosten Abstimmung
und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt. Auch hier gilt: messen,
nicht schätzen.
**Zweitens — und das ist der Befund, der die Testpraxis bestimmt: Der Seed genügt nicht.**
Bei einem Arbeiter liefern vier Läufe **einen** Plan. Schon bei **zwei** Arbeitern liefern
vier Läufe **drei verschiedene** — bei identischem `random_seed` und identischem Zielwert
183.
Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit zu verlieren. Zwei genügen.
Der Grund ist einfach, sobald man ihn ausspricht: Der Seed legt fest, wie ein **einzelner**
Suchstrang würfelt. Er legt nicht fest, **welcher von mehreren zuerst fertig wird** — das
entscheidet die Uhr, und die ist bei jedem Lauf anders.
> **⚠️ `random_seed` allein macht nichts reproduzierbar**
> Ein häufiger Irrtum: „Ich habe doch einen Seed gesetzt.“ Für einen Zufallsgenerator
> stimmt das. Für einen **parallelen** Solver nicht — dort ist die zweite Zufallsquelle die
> Ausführungsreihenfolge, und die lässt sich nicht mit einem Parameter festnageln.
> Reproduzierbar wird der Lauf erst mit `num_workers = 1` **und** Seed. Wer nur eines von
> beidem setzt, hat einen Test, der gelegentlich grundlos rot wird.
### Was daraus für Tests folgt
Der Zielwert war in **allen** sechzehn Läufen derselbe: 183. Die Gestalt der Lösung war es
nicht. Genau daran entscheidet sich, was ein Test prüfen darf:
| Prüfen | Nicht prüfen |
| --- | --- |
| den **Zielwert** — er ist eindeutig, wenn das Modell optimal gelöst wurde | die **Gestalt** des Plans (`assert plan == erwarteter_plan`) |
| die **Einhaltung aller Regeln** über eine Abnahmeprüfung ({ref:sec:praxisfallen-or-kern}) | die Reihenfolge, in der gleichwertige Aufträge zugewiesen wurden |
| den **Status** (`OPTIMAL`, `FEASIBLE`, `INFEASIBLE`) | die Laufzeit als feste Zahl |
Wer doch einen bestimmten Plan braucht — für eine abgedruckte Ausgabe, für einen
Regressionstest —, setzt `num_workers = 1`. Das kostet hier den Faktor 12 an Laufzeit, und
genau deshalb ist es eine Entscheidung **für den Test**, nicht für den Betrieb. In
Produktion lässt man beide Parameter auf ihren Standardwerten.
> **🎯 Merksatz**
> Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr. Nur die erste lässt
> sich setzen. Wer Reproduzierbarkeit braucht, muss die zweite abschalten — und das heißt
> `num_workers = 1`.
---
## Die fünf Antworten von CP-SAT {#sec:cpsat-statusfaelle}
{ref:sec:milp-gap} hat gezeigt, warum `OPTIMAL` bei ganzzahligen Problemen nicht der
@ -1180,6 +1516,16 @@ Lösen Sie ein 9×9-Sudoku mit CP-SAT. Sie brauchen nur `NewIntVar(1, 9, ...)` u
`AddAllDifferent` für Zeilen, Spalten und die neun 3×3-Blöcke. Wie viele Zeilen Modellcode
benötigen Sie?
**Aufgabe ⭐⭐ — Die Grenze der Parallelität finden.**
`Parallele_Suche.py` misst 1, 2, 4 und 8 Arbeiter.
(a) Ergänzen Sie 16 und 24 (bzw. die Kernzahl Ihres Rechners). Ab wo bringt ein weiterer
Arbeiter nichts mehr — und wird es irgendwann wieder schlechter?
(b) Setzen Sie `random_seed` bei jedem Lauf auf einen **anderen** Wert, aber
`num_workers = 1`. Wie viele verschiedene Pläne kommen jetzt heraus? Was folgt daraus
für die Frage, welcher der beiden Parameter die Reproduzierbarkeit sichert?
(c) Vergrößern Sie die Instanz auf 15 Aufträge. Ab welcher Größe erreicht der Lauf mit
einem Arbeiter das Zeitlimit, während acht noch `OPTIMAL` melden?
**Aufgabe ⭐⭐⭐ — Job-Shop erweitern.**
Ergänzen Sie `JobShop_Intervalle.py` um:
(a) Rüstzeiten von 1 Zeiteinheit zwischen zwei Aufträgen auf derselben Maschine.
@ -1466,6 +1812,10 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
3. Was ist eine Intervallvariable, und welche Bedingung garantiert sie automatisch?
4. Warum sollten möglichst wenige Bedingungen hart formuliert werden?
5. Warum ist die Aufschlüsselung der Strafkosten in der Praxis wichtiger als der Zielwert
6. Sie setzen `random_seed = 1`, bekommen aber trotzdem bei jedem Lauf einen anderen Plan.
Woran liegt das, und was hilft?
7. Warum kann ein CP-SAT-Lauf mit acht Arbeitern **mehr** als achtmal schneller sein als
mit einem?
selbst?
---
@ -1491,6 +1841,13 @@ Nebenbedingungen. Alles, was einen Preis hat, wird irgendwann gekauft.
* **Bei mehrdeutigen Optima nie auf einen bestimmten Plan testen** — nur auf den Zielwert
und die Einhaltung aller Regeln.
* **Ein paralleler Solver hat zwei Zufallsquellen: den Seed und die Uhr.** Nur die erste
lässt sich setzen. Reproduzierbar wird ein Lauf erst mit `num_workers = 1` **und** Seed —
gemessen in {ref:sec:cpsat-parallele-suche}: schon zwei Arbeiter liefern bei identischem
Seed drei verschiedene Pläne zum selben Zielwert.
* **Parallele Suche kann überlinear beschleunigen**, weil CP-SAT nicht dieselbe Suche
vervielfacht, sondern verschiedene Strategien nebeneinander laufen lässt, die einander
ihre Schranken mitteilen.
**Ausblick.** {ref:kap:graphen} behandelt Probleme, die sich am besten als **Graph** begreifen
lassen: Flüsse durch Netzwerke, Zuordnungen und die Tourenplanung mit Fahrzeugen und
Zeitfenstern.

View file

@ -850,6 +850,31 @@ for i in range(9):
**Etwa 10 Zeilen Modellcode** — und der Solver löst jedes Sudoku in Millisekunden. Das ist
die Stärke globaler Constraints.
**{loesung} — Die Grenze der Parallelität finden.**
(a) Der Gewinn flacht ab und kehrt sich um. Auf einem Rechner mit 24 Kernen gemessen
(Faktor gegenüber einem Arbeiter): 8 Arbeiter **10,2**, 16 Arbeiter **12,3**,
24 Arbeiter **9,0**. Wer alle Kerne belegt, ist langsamer als mit zwei Dritteln — dem
Betriebssystem, dem Speicherbus und den Suchsträngen untereinander bleibt zu wenig Luft.
Die Faustregel „so viele Arbeiter wie Kerne" ist also falsch; die richtige Zahl ist eine
Messung, keine Einstellung.
(b) **Fünf verschiedene Pläne aus fünf Seeds** — bei `num_workers = 1` und identischem
Zielwert 183. Das ist die andere Hälfte der Antwort: Der Seed *wirkt*, aber nur auf den
einzelnen Suchstrang. Es sichert also **keiner der beiden Parameter allein** die
Reproduzierbarkeit:
| | Seed fest | Seed variabel |
| --- | --- | --- |
| **1 Arbeiter** | reproduzierbar | 5 Pläne aus 5 Läufen |
| **mehrere Arbeiter** | 3 Pläne aus 4 Läufen | erst recht nicht |
Nur die Kombination trägt.
(c) Bei **15 Aufträgen** (60 s Zeitlimit): Ein Arbeiter läuft ins Limit und meldet
`FEASIBLE` mit Makespan 200; acht Arbeiter melden `OPTIMAL` mit **demselben** Makespan 200
nach 26,4 s. Bei 13 Aufträgen sind es 11,2 s gegen 2,8 s, bei 14 schon 21,4 s gegen 3,7 s.
Bemerkenswert ist, dass beide denselben Wert finden — der Unterschied liegt nicht in der
Lösung, sondern im **Beweis**, dass es keine bessere gibt. Genau diese Arbeit teilen sich
die parallelen Stränge auf.
**{loesung} — Job-Shop erweitern.**
(a) Rüstzeiten: `AddNoOverlap` durch paarweise Disjunktionen mit Übergangszeit ersetzen,
oder `AddCircuit` je Maschine mit Übergangsmatrix.
@ -950,6 +975,15 @@ Regeln. Wer eine reproduzierbare Ausgabe braucht — etwa für ein Buch —, fix
4. Weil jede zusätzliche harte Bedingung das Risiko von `INFEASIBLE` erhöht — und eine
Fehlermeldung dem Anwender nicht hilft.
5. Weil Anwender die Frage „Warum ich?“ stellen. Ohne Antwort wird das System umgangen.
6. Weil der Seed nur festlegt, wie ein **einzelner** Suchstrang würfelt — nicht, welcher
von mehreren parallelen zuerst fertig wird. Das entscheidet die Ausführungsreihenfolge,
also die Uhr. Abhilfe: zusätzlich `num_workers = 1`. Gemessen: schon zwei Arbeiter
liefern bei identischem Seed drei verschiedene Pläne zum selben Zielwert.
7. Weil CP-SAT nicht dieselbe Suche vervielfacht, sondern **verschiedene Strategien**
nebeneinander laufen lässt (LP-gestützt, andere Verzweigungsregeln, lokale Suche), die
einander ihre Schranken mitteilen. Ein zusätzlicher Arbeiter ist deshalb nicht ein
weiterer Kern für dieselbe Arbeit, sondern ein anderes Verfahren — und wenn eines davon
früh eine gute Schranke findet, profitieren alle.
---