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

@ -496,6 +496,218 @@
" loese_jobshop()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Parallele Suche: was `num_workers` wirklich bewirkt\n",
"\n",
"`Parallele_Suche.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Parallele_Suche.py\n",
"\"\"\"\n",
"Kapitel CP-SAT: Was mehrere Arbeiter wirklich bringen - und was sie kosten.\n",
"\n",
"Das Buch setzt an mehreren Stellen 'num_workers = 1', damit die abgedruckte\n",
"Ausgabe reproduzierbar ist. Behauptet wird dabei zweierlei: dass parallele\n",
"Suche schneller ist, und dass sie von Lauf zu Lauf verschiedene, gleich gute\n",
"Loesungen findet. Beides wird hier gemessen statt geglaubt.\n",
"\n",
"Gerechnet wird auf einem Job-Shop wie in JobShop_Intervalle.py, nur gross\n",
"genug, dass die Suche wirklich arbeitet: 12 Auftraege auf 10 Maschinen,\n",
"jeder Auftrag besucht jede Maschine.\n",
"\n",
"Zwei Messungen:\n",
" 1. Dieselbe Aufgabe mit 1, 2, 4 und 8 Arbeitern - Laufzeit und Ergebnis.\n",
" 2. Dieselbe Konfiguration mehrfach, bei FESTEM random_seed - wie oft kommt\n",
" derselbe Plan heraus?\n",
"\n",
"Die zweite ist die wichtigere: Sie entscheidet, wie man Tests schreibt.\n",
"\n",
"Achtung, Laufzeiten sind hardwareabhaengig. Die ZAHL der verschiedenen\n",
"Plaene ist es auch - und genau das ist die Aussage.\n",
"\n",
"Benoetigt: numpy, ortools\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import collections\n",
"import time\n",
"\n",
"import numpy as np\n",
"from ortools.sat.python import cp_model\n",
"\n",
"AUFTRAEGE = 12\n",
"MASCHINEN = 10\n",
"INSTANZ_SEED = 20260908\n",
"SOLVER_SEED = 1 # bleibt ueber ALLE Laeufe gleich - das ist der Punkt\n",
"ARBEITERZAHLEN = (1, 2, 4, 8)\n",
"WIEDERHOLUNGEN = 4\n",
"ZEITLIMIT = 120.0\n",
"\n",
"\n",
"def baue_instanz() -> list[list[tuple[int, int]]]:\n",
" \"\"\"Klassischer Job-Shop: Jeder Auftrag besucht jede Maschine genau einmal,\n",
" in einer eigenen zufaelligen Reihenfolge.\"\"\"\n",
" rng = np.random.default_rng(INSTANZ_SEED)\n",
" return [[(int(m), int(rng.integers(2, 20))) for m in rng.permutation(MASCHINEN)]\n",
" for _ in range(AUFTRAEGE)]\n",
"\n",
"\n",
"def loese(auftraege, arbeiter: int):\n",
" \"\"\"Minimiert den Makespan. Gibt Status, Zielwert, Laufzeit und den Plan\n",
" zurueck - den Plan als Tupel aller Startzeiten, damit sich zwei Laeufe\n",
" vergleichen lassen.\"\"\"\n",
" horizont = sum(dauer for auftrag in auftraege for _, dauer in auftrag)\n",
" modell = cp_model.CpModel()\n",
" Gang = collections.namedtuple(\"Gang\", \"start ende intervall\")\n",
" plaene: dict[tuple[int, int], Gang] = {}\n",
" je_maschine = collections.defaultdict(list)\n",
"\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g, (maschine, dauer) in enumerate(auftrag):\n",
" start = modell.NewIntVar(0, horizont, f\"start_{a}_{g}\")\n",
" ende = modell.NewIntVar(0, horizont, f\"ende_{a}_{g}\")\n",
" intervall = modell.NewIntervalVar(start, dauer, ende, f\"iv_{a}_{g}\")\n",
" plaene[a, g] = Gang(start, ende, intervall)\n",
" je_maschine[maschine].append(intervall)\n",
"\n",
" for maschine in range(MASCHINEN):\n",
" modell.AddNoOverlap(je_maschine[maschine])\n",
" for a, auftrag in enumerate(auftraege):\n",
" for g in range(len(auftrag) - 1):\n",
" modell.Add(plaene[a, g + 1].start >= plaene[a, g].ende)\n",
"\n",
" makespan = modell.NewIntVar(0, horizont, \"makespan\")\n",
" modell.AddMaxEquality(\n",
" makespan, [plaene[a, len(auftrag) - 1].ende\n",
" for a, auftrag in enumerate(auftraege)])\n",
" modell.Minimize(makespan)\n",
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.num_workers = arbeiter\n",
" loeser.parameters.random_seed = SOLVER_SEED\n",
" loeser.parameters.max_time_in_seconds = ZEITLIMIT\n",
"\n",
" beginn = time.perf_counter()\n",
" status = loeser.Solve(modell)\n",
" dauer = time.perf_counter() - beginn\n",
"\n",
" if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):\n",
" return loeser.StatusName(status), None, dauer, None\n",
" plan = tuple(loeser.Value(plaene[a, g].start)\n",
" for a, auftrag in enumerate(auftraege)\n",
" for g in range(len(auftrag)))\n",
" return loeser.StatusName(status), int(loeser.ObjectiveValue()), dauer, plan\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" auftraege = baue_instanz()\n",
" gaenge = sum(len(a) for a in auftraege)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" WAS MEHRERE ARBEITER WIRKLICH BRINGEN\")\n",
" print(\"=\" * 78)\n",
" print(f\"Job-Shop: {AUFTRAEGE} Auftraege, {MASCHINEN} Maschinen, \"\n",
" f\"{gaenge} Arbeitsgaenge.\")\n",
" print(f\"random_seed = {SOLVER_SEED} in JEDEM Lauf, \"\n",
" f\"{WIEDERHOLUNGEN} Wiederholungen je Arbeiterzahl.\\n\")\n",
"\n",
" # Jede Konfiguration mehrfach - ein einzelner Zeitwert sagt nichts.\n",
" ergebnisse: dict[int, list[tuple]] = {}\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" ergebnisse[arbeiter] = [loese(auftraege, arbeiter)\n",
" for _ in range(WIEDERHOLUNGEN)]\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" (1) Laufzeit: was die Arbeiter bringen\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Status':>9} {'Makespan':>9} \"\n",
" f\"{'schnellste':>11} {'langsamste':>11} {'Beschleunigung':>15}\")\n",
" print(\"-\" * 78)\n",
" basis_zeit = min(d for _, _, d, _ in ergebnisse[1])\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" laeufe = ergebnisse[arbeiter]\n",
" zeiten = [d for _, _, d, _ in laeufe]\n",
" ziele = {o for _, o, _, _ in laeufe}\n",
" status = {s for s, _, _, _ in laeufe}\n",
" print(f\"{arbeiter:>9} {'/'.join(sorted(status)):>9} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>9} \"\n",
" f\"{min(zeiten):>10.2f}s {max(zeiten):>10.2f}s \"\n",
" f\"{basis_zeit / min(zeiten):>14.1f}x\")\n",
"\n",
" beste = min(ARBEITERZAHLEN, key=lambda w: min(d for _, _, d, _ in ergebnisse[w]))\n",
" faktor = basis_zeit / min(d for _, _, d, _ in ergebnisse[beste])\n",
" print(f\"\\n Am schnellsten sind {beste} Arbeiter: Faktor {faktor:.1f} gegenueber\")\n",
" print(f\" einem einzigen - also MEHR, als {beste} Kerne hergeben sollten.\")\n",
" print(f\" Das ist kein Messfehler. CP-SAT laesst nicht {beste}-mal dieselbe Suche\")\n",
" print(\" laufen, sondern verschiedene Strategien nebeneinander, die einander\")\n",
" print(\" ihre Schranken zurufen. Der zweite Arbeiter ist deshalb nicht der\")\n",
" print(\" 'zweite Kern', sondern ein anderes Verfahren.\")\n",
" letzte = ARBEITERZAHLEN[-1]\n",
" if beste != letzte:\n",
" zeit_letzte = min(d for _, _, d, _ in ergebnisse[letzte])\n",
" print(f\"\\n Und mehr ist nicht immer besser: {letzte} Arbeiter brauchen \"\n",
" f\"{zeit_letzte:.2f}s\")\n",
" print(f\" gegenueber {min(d for _, _, d, _ in ergebnisse[beste]):.2f}s bei \"\n",
" f\"{beste}. Ab einer gewissen Zahl kosten Abstimmung\")\n",
" print(\" und Speicherbandbreite mehr, als ein weiterer Suchstrang einbringt.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (2) Reproduzierbarkeit: derselbe Seed, derselbe Plan?\")\n",
" print(\"=\" * 78)\n",
" print(f\"{'Arbeiter':>9} {'Laeufe':>7} {'verschiedene Plaene':>21} \"\n",
" f\"{'Makespan':>10}\")\n",
" print(\"-\" * 78)\n",
" for arbeiter in ARBEITERZAHLEN:\n",
" plaene = {p for _, _, _, p in ergebnisse[arbeiter] if p is not None}\n",
" ziele = {o for _, o, _, _ in ergebnisse[arbeiter]}\n",
" print(f\"{arbeiter:>9} {WIEDERHOLUNGEN:>7} {len(plaene):>21} \"\n",
" f\"{'/'.join(str(z) for z in sorted(ziele)):>10}\")\n",
"\n",
" einer = len({p for _, _, _, p in ergebnisse[1]})\n",
" # Ab welcher Arbeiterzahl bricht die Reproduzierbarkeit? Das ist die\n",
" # eigentliche Zahl - nicht, was acht Arbeiter anrichten.\n",
" ab = next((w for w in ARBEITERZAHLEN\n",
" if len({p for _, _, _, p in ergebnisse[w]}) > 1), None)\n",
" print(f\"\\n Mit einem Arbeiter: {einer} Plan aus {WIEDERHOLUNGEN} Laeufen.\")\n",
" if ab is not None:\n",
" viele = len({p for _, _, _, p in ergebnisse[ab]})\n",
" print(f\" Schon mit {ab} Arbeitern: {viele} verschiedene Plaene aus \"\n",
" f\"{WIEDERHOLUNGEN} Laeufen -\")\n",
" print(\" bei identischem random_seed und identischem Zielwert.\")\n",
" print(f\" Es braucht also keine acht Arbeiter, um die Reproduzierbarkeit\")\n",
" print(f\" zu verlieren. {ab} genuegen.\")\n",
" print(\"\\n Der Seed allein macht einen Lauf also NICHT reproduzierbar. Er legt\")\n",
" print(\" fest, wie ein einzelner Suchstrang wuerfelt - nicht, welcher von\")\n",
" print(\" mehreren zuerst fertig wird. Das entscheidet die Uhr.\")\n",
"\n",
" print(\"\\n\" + \"=\" * 78)\n",
" print(\" (3) Was daraus fuer Tests folgt\")\n",
" print(\"=\" * 78)\n",
" print(\" Ein Test der Form assert plan == erwarteter_plan besteht mal und\")\n",
" print(\" scheitert mal, ohne dass sich am Code etwas geaendert haette.\")\n",
" print(\" Zu pruefen sind stattdessen:\")\n",
" print(\" * der ZIELWERT (hier in allen Laeufen gleich),\")\n",
" print(\" * die Einhaltung aller Regeln (Abnahmepruefung, Kapitel Praxisfallen),\")\n",
" print(\" * und der Status - nicht die Gestalt der Loesung.\")\n",
" print(\"\\n Wer doch einen bestimmten Plan braucht - fuer eine abgedruckte\")\n",
" print(\" Ausgabe, fuer einen Regressionstest -, setzt num_workers = 1.\")\n",
" print(f\" Das kostet hier den Faktor {faktor:.1f} an Laufzeit und ist genau\")\n",
" print(\" deshalb eine Entscheidung fuer den Test, nicht fuer den Betrieb.\")\n",
" print(\"=\" * 78)"
]
},
{
"cell_type": "markdown",
"metadata": {},

View file

@ -757,7 +757,8 @@
"\n",
" loeser = cp_model.CpSolver()\n",
" loeser.parameters.max_time_in_seconds = zeitlimit\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist.\n",
" # Ein Arbeiter und fester Startwert, damit die Messung reproduzierbar ist -\n",
" # der Seed allein genuegt dafuer NICHT (Kapitel Constraint Programming).\n",
" # Im Produktivbetrieb laesst man beides auf den Standardwerten.\n",
" loeser.parameters.num_workers = 1\n",
" loeser.parameters.random_seed = 1\n",