Zum Hauptinhalt springen

Programmiermodelle

Programmiermodelle sind grundlegende Spezifikationen, die definieren, wie Software strukturiert und ausgeführt wird. Sie bieten Entwicklenden ein Framework, um Algorithmen auszudrücken und Code zu organisieren, und abstrahieren dabei oft die Details der zugrunde liegenden Hardware oder Ausführungsumgebung. Verschiedene Modelle eignen sich für unterschiedliche Problemtypen und Hardwarearchitekturen und bieten unterschiedliche Abstraktions- und Steuerungsebenen.

In dieser Lektion werden wir Quanten- und klassische Programmiermodelle behandeln und zeigen, wie wir sie kombinieren können, um Algorithmen in heterogenen Umgebungen auszuführen. Iskandar Sitdikov gibt uns im folgenden Video einen Überblick.

Programmiermodell für QPUs

Wir beginnen mit dem Programmiermodell für Quantencomputer. Das grundlegende Programmiermodell, das nahezu allen Quantenentwicklenden vertraut ist, ist der Quantum Circuit. Wir werden hier nicht auf die Details des Quantum-Circuit-Modells eingehen, da wir bereits eine hervorragende Vorlesung von John Watrous haben, die dies ausführlich erklärt. Wir erwähnen nur, dass ein Circuit aus einer Reihe von Linien (sogenannten Drähten) besteht, die Qubits repräsentieren, aus Gates, die Operationen auf Quantenzuständen darstellen, sowie aus einer Reihe von Messungen.

Ein Quantum-Circuit-Diagramm mit Qubits als horizontale Linien und Quantum Gates als Kästen oder Verbindungen zwischen Qubits.

Ein weiteres wichtiges Programmiermodellkonzept für Quantencomputing sind die sogenannten Computational Primitives. Diese Primitiven repräsentieren einige der häufigsten Aufgaben, die Nutzende mit einem Quantencomputer ausführen möchten. Derzeit stehen mehrere Primitiven zur Verfügung, darunter Executor. In diesem Kurs konzentrieren wir uns hauptsächlich auf die Primitiven Sampler und Estimator. Sampler gibt dir die Möglichkeit, einen durch deinen Quantum Circuit vorbereiteten Zustand abzutasten. Er sagt dir, welche Berechnungsbasisstände den auf deinem Quantum Circuit vorbereiteten Quantenzustand bilden. Estimator ermöglicht es dir, den Erwartungswert einer Observablen für ein System im durch deinen Quantum Circuit vorbereiteten Zustand zu schätzen. Ein häufiger Anwendungsfall ist die Schätzung der Energie eines Systems in einem bestimmten Zustand.

Ein modellartiges Histogramm der Ergebnisse des Samplers. Manche Zustände werden sehr wahrscheinlich gemessen, andere sehr unwahrscheinlich.

Das Letzte, worüber wir in diesem Abschnitt sprechen werden, ist die Transpilation. Transpilation ist der Prozess, einen gegebenen Eingabe-Circuit so umzuschreiben, dass er den physischen Einschränkungen und der Instruction Set Architecture (ISA) eines bestimmten Quantengeräts entspricht. Ähnlich wie klassische Compiler bedeutet dies, abstrakte unitäre Operationen in den nativen Gate-Satz zu übersetzen, den das Zielgerät ausführen kann. Außerdem optimiert es die Circuit-Anweisungen für eine effiziente Ausführung auf rauschenden Quantencomputern, wobei die Routine die Struktur des Circuits schrittweise durch mehrere Optimierungsphasen verändert.

Ein Transpilationsdiagramm, das zeigt, wie ein abstrakter Circuit in einen ISA-Circuit umgewandelt wird. Das heißt, der Circuit wird mithilfe der nativen Gates und Konnektivität der Zielhardware neu geschrieben.

Überprüfe dein Verständnis

Wie viele Qubits enthält der folgende Circuit? Ein Circuit-Diagramm mit vier horizontalen Linien und vielen Gates.

Antwort:

Vier.

Überprüfe dein Verständnis

Angenommen, du modellierst die Elektronen in einem Molekül. Du möchtest (a) die Grundzustandsenergie des Moleküls approximieren und (b) herausfinden, welche Berechnungsbasisstände im Grundzustand des Moleküls am stärksten dominieren. Welche Primitive würdest du in jedem Fall verwenden – Estimator oder Sampler?

Antwort:

(a) Estimator (b) Sampler

Klassische Programmiermodelle

Es gibt viele Programmiermodelle für klassische Computer, aber in diesem Abschnitt konzentrieren wir uns auf zwei der beliebtesten: Parallelprogrammierung und Task-Workflows. Mit diesen beiden Modellen zusammen mit Quantenprogrammiermodellen lässt sich nahezu jeder hybride Quanten-Klassik-Workflow beliebiger Komplexität beschreiben.

Parallelprogrammierung

Parallelprogrammierung ist ein Modell, das ein Programm in Teilprobleme aufteilt, die gleichzeitig ausgeführt werden können. Es gibt zwei Hauptparadigmen der Parallelprogrammierung:

  • Gemeinsamer Speicher (Open Multiprocessing, oder OpenMP): Wird genutzt, um mehrere Kerne innerhalb eines einzelnen Rechenknotens auszulasten. Ausführungs-Threads teilen sich einen gemeinsamen Speicherbereich.

  • Verteilter Speicher (Message Passing Interface, oder MPI): Wird für die Skalierung über mehrere separate Rechenknoten hinweg eingesetzt. Jeder Prozess hat seinen eigenen isolierten Speicherbereich.

Hier konzentrieren wir uns auf das verteilte Speichermodell, da es für Multi-Knoten-Supercomputing und die Koordination groß angelegter heterogener Quanten-Klassik-Jobs unerlässlich ist.

Es gibt einige Konzepte, die wir verstehen müssen, um in verteilten Speicher-Parallelprogrammiermodellen zu arbeiten:

  • Prozess – Eine unabhängige Instanz des Programms mit eigenem Speicherbereich.
  • Rang – Ein eindeutiger ganzzahliger Bezeichner, der jedem Prozess zugewiesen wird und speziell zur Identifikation von Sender und Empfänger bei der Kommunikation verwendet wird (nicht unbedingt ein „Rang" im Sinne einer Priorisierung).
  • Synchronisation – Ein Mechanismus zur Koordination zwischen verschiedenen Rängen und Prozessen.
  • Single Program, Multiple Data (SPMD) – Ein abstraktes Berechnungsmodell, bei dem eine einzelne Quellcodeinstanz gleichzeitig auf mehreren Prozessen ausgeführt wird, wobei jeder auf einem anderen Subset der Gesamtdaten arbeitet.
  • Message Passing – Das Kommunikationsparadigma in verteilten Speicherarchitekturen, das unabhängigen Prozessen den Austausch von Daten und Zwischenergebnissen ermöglicht. Es beruht auf expliziten „Send"- und „Receive"-Operationen zur Koordination der Ausführung zwischen verschiedenen Rechenknoten.

Es gibt einen Standard namens MPI, der dieses Message-Passing-Paradigma für Parallelarchitekturen implementiert. MPI ist die funktionale Verkörperung all dieser Konzepte und stellt die spezifischen Bibliotheksaufrufe bereit, die zur Verwaltung von Prozessen, Zuweisung von Rängen, Erleichterung der Synchronisation und Ermöglichung von Message Passing unter dem SPMD-Modell notwendig sind. Fasst man all diese Konzepte zusammen, lässt sich die Ausführung eines Parallelprogramms wie folgt beschreiben:

  • Ein einzelnes kompiliertes Programm (dieselbe Binärdatei) wird von einem Job-Launcher kopiert und ausgeführt, um mehrere parallele Prozesse auf mehreren Knoten zu erstellen.
  • Der Hauptkontrollfluss des Programms wird durch den Rang des Prozesses bestimmt. Dies ist das SPMD-Prinzip in Aktion: Das Programm verwendet bedingte Logik (z. B. if (rank == 0)), um sicherzustellen, dass nur bestimmte, parallelisierte Abschnitte des Codes von den Worker-Prozessen ausgeführt werden, während ein Master-Prozess (oft Rank 0) die Initialisierung und abschließende Aggregation übernimmt.
  • Die Kommunikation zwischen Prozessen erfolgt über Message Passing (mit MPI), das immer dann aufgerufen wird, wenn ein Prozess Daten oder Zwischenergebnisse mit einem anderen Rang austauschen muss.

Visuell sieht das ungefähr so aus:

Ein Diagramm einer Aufgabe, die zwischen Knoten aufgeteilt wird.

Versuchen wir nun, einige der gerade gelernten Konzepte in Code umzusetzen.

Zunächst versuchen wir, ein einfaches „Hello World"-Parallelprogramm mit OpenMPI auszuführen, einer Implementierung des MPI-Protokolls, einem Standard für Message Passing in der Parallelprogrammierung. Hier verwenden wir das Python-Paket mpi4py, das eine Python-Anbindung an den Message Passing Interface (MPI)-Standard bietet.

$ vim mpi-hello-world.py
from mpi4py import MPI
import sys

comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()

sys.stdout.write(f"[Rank {rank}] Hello from process {rank} of {size}!\n")

if rank == 0:
data = {'answer': 42, 'pi': 3.14}
sys.stdout.write(f"[Rank {rank}] Sending: {data}\n")
comm.send(data, dest=1, tag=42)
elif rank == 1:
data = comm.recv(source=0, tag=42)
sys.stdout.write(f"[Rank {rank}] Received: {data}\n")

~
~

Wir werden zwei Knoten verwenden, um dieses Programm auszuführen, was wir in unserem Submission-Skript angeben.

$ vim mpi-hello-world.sh

#!/bin/bash
#
#SBATCH --job-name=mpi-hello-world
#SBATCH --output=mpi-hello-world.out
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=normal

/usr/lib64/openmpi/bin/mpirun python /data/ch3/parallel/mpi-hello-world.py

Dann führen wir das Shell-Skript aus.

$ sbatch mpi-hello-world.sh

Wir können die Ergebnisprotokolle des Jobs überprüfen.

$ cat mpi-hello-world.out | grep Rank

[Rank 1] Hello from process 1 of 2!
[Rank 0] Hello from process 0 of 2!
[Rank 0] Sending: {'answer': 42, 'pi': 3.14}
[Rank 1] Received: {'answer': 42, 'pi': 3.14}

Hier haben wir zwei Knoten verwendet, und der Prozess auf jedem Knoten wird nun durch einen Rang identifiziert – Rank 0 und Rank 1 – die verwendet werden, um den Programmkontrollfluss zu steuern.

Task-Workflows

Kommen wir jetzt zum Task-Workflow-Programmiermodell. Ein Task-Workflow abstrahiert Berechnungen in einen gerichteten azyklischen Graphen (DAG). In diesem Graphen repräsentiert jeder Knoten eine bestimmte Aufgabe oder einen Job, und die Kanten (die Pfeile, die die Knoten verbinden) repräsentieren die Abhängigkeiten (Daten und Reihenfolge) zwischen ihnen. Ein Scheduler ist die Komponente, die Aufgaben Ressourcen zuweist und die Ausführung orchestriert.

Ein konkretes Beispiel für ein Task-Workflow-Modell, das auf Quantencomputing angewendet wird, ist das Qiskit-Patterns-Framework. Ein Qiskit-Pattern ist ein allgemeines Framework, das domänenspezifische Probleme in eine Folge von Phasen zerlegt, insbesondere für Quantenaufgaben. Dies ermöglicht die nahtlose Komponierbarkeit neuer Fähigkeiten, die von IBM Quantum®-Forschenden (und anderen) entwickelt wurden, und ermöglicht eine Zukunft, in der Quantenrechenaufgaben von leistungsstarker heterogener (CPU/GPU/QPU) Recheninfrastruktur ausgeführt werden. Die vier Schritte eines Qiskit-Patterns sind Mapping, Optimierung, Ausführung und Nachverarbeitung, wobei alle Aufgaben nacheinander in einer Pipeline ausgeführt werden. Mit Task-Workflows sind wir jedoch nicht an eine lineare Ausführungsreihenfolge gebunden und können Aufgaben parallel ausführen. Jede Aufgabe eines Workflows kann selbst ein ganzer paralleler Job sein. So kann man diese Modelle beliebig kombinieren, um Algorithmen beliebiger Komplexität zu beschreiben, und ein Workload-Manager wie Slurm übernimmt die Verwaltung.

Ein Diagramm von Rechenaufgaben, die in einem Workflow organisiert sind, in dem einige Prozesse parallel und andere sequentiell ausgeführt werden.

Das obige Bild zeigt das Qiskit-Pattern in Aktion. Der Workflow hat eine Graphstruktur mit vier Phasen. Diese verzweigte Struktur wird vom Scheduler orchestriert und ausgeführt. Das Problem wird in der Anfangsphase in eine quantenausführbare Form (Quantum Circuit) umgewandelt. In der nächsten Phase wird dieser Quantum Circuit für die spezifische Quantenhardware optimiert. Das Bild zeigt dies als parallelen Prozess, was veranschaulicht, wie mehrere Optimierungsstrategien gleichzeitig angewendet werden könnten. Der optimierte Quantum Circuit wird dann auf der eigentlichen Quantenhardware ausgeführt. Dies ist die dritte Phase des Bildes, in der der Scheduler mit einer lila Quantenprozesseinheit arbeitet. Schließlich werden die Ergebnisse von klassischen Ressourcen nachverarbeitet.

Warum beides?

Warum brauchen wir also sowohl Parallelprogrammierung als auch Task-Workflows? Bei all dem Gerede über Quantenparallelismus lohnt es sich klarzustellen, dass beim Quantencomputing nicht alles parallel läuft.

Die vorherige Lektion zum SQD-Workflow erwähnte einige Prozesse, die nicht parallelisiert werden können. Zum Beispiel benötigen wir die Ergebnisse vieler Quantenmessungen, um unsere Matrix in einen Unterraum handhabbarer Dimension zu projizieren. Dazu benötigen wir wiederum die diagonalisierte Matrix und die zugehörigen Zustandsvektoren, um die Selbstkonsistenz der Quantenmessungen zu überprüfen (z. B. anhand der Ladungserhaltung). Danach müssen wir entscheiden, ob die Grundzustandsenergie für unsere Zwecke ausreichend konvergiert ist. Diese Schritte sind notwendigerweise sequentiell und erfordern das Testen von Konvergenz- und Selbstkonsistenzbedingungen, bevor weitergemacht werden kann.

Eine schematische Darstellung des Workflows speziell für Sample-based Quantum Diagonalization. Die Schritte umfassen einen variationellen Quantum Circuit, die Verwendung von Messungen zur Projektion des Hamiltonoperators in einen Unterraum, die Verwendung eines klassischen Optimierers zur Aktualisierung variativer Parameter im Circuit und Wiederholung.

Dieser Workflow wird im nächsten Abschnitt ausführlicher behandelt und implementiert. Das Einzige, was du aus diesem Abschnitt mitnehmen musst, ist, dass Task-Workflows notwendig sind.

Programmierpraxis

Das Schöne an Programmiermodellen ist, dass man sie alle kombinieren kann. Mit Kenntnissen über Quanten- und klassische Programmiermodelle kannst du eine heterogene Berechnung beliebiger Komplexität beschreiben und auf Hardware ausführen. Üben wir dies mit einem kleinen Beispiel eines kombinierten Workflows, der das Qiskit-Pattern (Map, Optimize, Execute und Post-Process) innerhalb von Slurm implementiert, das wir im letzten Kapitel gelernt haben. Jede der vier Aufgaben ist ein separater Slurm-Job mit eigenen Ressourcen. Die Optimierungsaufgabe verwendet MPI, um Circuits parallel zu optimieren (nur als Beispiel, wie im obigen Bild). Die Ausführungsaufgabe verwendet Quantenressourcen und Quantenprogrammiermodelle (Circuit und Sampler). Die letzte Aufgabe – Nachverarbeitung – verwendet wieder MPI parallel mit klassischen Ressourcen.

Mapping

Das Programm mapping.py ist dafür ausgelegt, einen PauliTwoDesign-Circuit zu erstellen, der häufig in der Literatur zum Quanten-Maschinellen-Lernen und in Quanten-Benchmark-Literatur verwendet wird, mit einer einfachen Observablen, die das (n1)th(n-1)^\text{th}-Qubit in der ZZ-Richtung eines nn-Qubit-Systems mit zufälligen Anfangsparametern misst. Jedes dieser Elemente (der in eine QASM-Datei konvertierte Quantum Circuit, die Observable und die Parameter) wird in einer separaten Datei im Datenverzeichnis gespeichert und als Eingabe in der Optimierungsphase verwendet.

Das Shell-Skript dieser Phase (mapping.sh) lautet

#!/bin/bash
#
#SBATCH --job-name=mapping
#SBATCH --output=mapping.out
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=normal

srun python /data/ch3/workflows/mapping.py

und definiert den Job-Namen, das Ausgabeformat und die Anzahl der Knoten/Aufgaben/CPUs.

Optimierung

Das Programm optimization.py beginnt damit, Dateien aus der Mapping-Phase zu laden. Hier verwendest du QRMI, um Quantenressourcen in dieses Programm einzubinden.

qrmi = QRMI()
resources = qrmi.resources()
quantum_resource = resources[0]
...

Anschließend führt es eine leichte Optimierung durch, indem es optimization_level=1 setzt, um den Quantum Circuit zu transpilieren und das Layout des Circuits auf die Observable anzuwenden, und speichert diese dann im Datenordner.

Das Shell-Skript dieser Phase (optimization.sh) lautet

#!/bin/bash
#SBATCH --job-name=optimization
#SBATCH --output=output/optimization.out
#SBATCH --ntasks=4
#SBATCH --partition=classical

srun python3 /tmp/optimization.py

Hier fordert --ntasks=4 vier klassische Aufgaben von Slurm für einen parallelen Prozess an.

Ausführung

Dies ist die zentrale Quantenphase, in der der optimierte Quantum Circuit aus dem vorherigen Schritt vom Estimator auf dem QPU ausgeführt wird. Dazu laden wir zunächst drei Dateien – den transpilierten Quantum Circuit, die Observable und die Anfangsparameter – und übergeben sie dann an den Estimator. Er liefert den geschätzten Wert der Observablen und gibt ihn aus.

Das Skript execution.sh nutzt ein Slurm-Plugin, um eine Quantenressource zu verwenden.

#!/bin/bash
#
#SBATCH --job-name=execution
#SBATCH --output=execution.out
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=quantum
#SBATCH --gres=qpu:1

srun python /data/ch3/workflows/execution.py

Nachverarbeitung

Der Nachverarbeitungsschritt umfasst oft klassische Diagonalisierung und Selbstkonsistenzprüfungen. Er kann auch iterativ sein. Es ist am sinnvollsten, den Nachverarbeitungsschritt in der nächsten Lektion zu betrachten, in der der physikalische Kontext und der Zweck iterativer Schritte klar werden.

Alles zusammenbringen

Wir können all diese Aufgaben in einen Workflow einbinden, indem wir das Abhängigkeitsargument für den sbatch-Befehl verwenden:

$ MAPPING_JOB=$(sbatch --parsable mapping.sh)
$ OPTIMIZE_JOB=$(sbatch --parsable --dependency=afterok:$MAPPING_JOB optimization.sh)
$ EXECUTE_JOB=$(sbatch --parsable --dependency=afterok:$OPTIMIZE_JOB execute.sh)

Und wir können unsere Slurm-Ausführungswarteschlange überprüfen.

$ squeue
# JOBID PARTITION NAME USER ST TIME NODES NODELIST(REASON)
# 3 classical mapping admin PD 0:00 1 (None)
# 4 classical optimiza admin PD 0:00 1 (Dependency)
# 5 quantum execute admin PD 0:00 1 (Dependency)

Dies war ein Spielzeugbeispiel, um die Kombination von Programmiermodellen zu demonstrieren. Im nächsten Kapitel werden wir uns reale Algorithmen ansehen und Programmiermodelle sowie Ressourcenverwaltung an nützlichen Workflows demonstrieren.

Zusammenfassung

In dieser Lektion haben wir gezeigt, wie man mehrere klassische und Quantenprogrammiermodelle kombiniert, um einen vollständigen vierstufigen Workflow zu erstellen, zu verwalten und auszuführen. Wir begannen mit den grundlegenden Konzepten von Quantum Circuits und Primitiven, erkundeten dann klassische Modelle wie Parallelprogrammierung und Task-Workflows. Durch die Kombination aller Konzepte konstruierten wir ein Qiskit-Pattern – Map, Optimize, Execute und Post-Process – orchestriert vom Slurm-Workload-Manager mit einem einfachen Quantum Circuit und einer Observablen.

In der nächsten Lektion werden wir dieses Framework nutzen, um sampling-basierte Quantenalgorithmen auszuführen und zu zeigen, wie dieser Workflow auf bedeutungsvolle Probleme angewendet werden kann.

Der gesamte Code und die Skripte aus diesem Kapitel stehen dir in diesem Github-Repository zur Verfügung.