Modele programowania
Modele programowania to fundamentalne specyfikacje definiujące sposób strukturyzowania i wykonywania oprogramowania. Stanowią one ramy, w których programiści mogą wyrażać algorytmy i organizować kod, często abstrahując od niskopoziomowych szczegółów sprzętu lub środowiska wykonawczego. Różne modele są dostosowane do różnych typów problemów i architektur sprzętowych, oferując różne poziomy abstrakcji i kontroli.
W tej lekcji omówimy kwantowe i klasyczne modele programowania oraz zobaczymy, jak można je łączyć, aby uruchamiać algorytmy w środowiskach heterogenicznych. Iskandar Sitdikov przedstawia przegląd w poniższym filmie.
Programming model for QPUs
Zaczniemy od modelu programowania dla komputerów kwantowych. Fundamentalnym modelem programowania, który jest znany niemal wszystkim programistom kwantowym, jest obwód kwantowy. Nie będziemy tutaj wchodzić w szczegóły modelu obwodów kwantowych, ponieważ mamy już świetny wykład Johna Watrousa wyjaśniający to szczegółowo. Wspomnimy tylko, że obwód zbudowany jest z zestawu linii (zwanych drutami) reprezentujących qubity, bramek reprezentujących operacje na stanach kwantowych oraz zestawu pomiarów.
Inną ważną koncepcją modelu programowania dla obliczeń kwantowych jest to, co nazywamy prymitywami obliczeniowymi. Te prymitywy reprezentują jedne z najczęstszych zadań, które użytkownicy chcą realizować za pomocą komputera kwantowego. Dostępnych jest kilka prymitywów, w tym Executor. W tym kursie skupimy się głównie na prymitywach Sampler i Estimator. Sampler daje ci możliwość próbkowania stanu przygotowanego przez twój obwód kwantowy. Mówi ci, które stany bazy obliczeniowej tworzą stan kwantowy przygotowany na twoim obwodzie kwantowym. Estimator pozwala ci szacować wartość oczekiwaną obserwowalnej dla układu w stanie przygotowanym przez twój obwód kwantowy. Typowym kontekstem jest szacowanie energii układu w określonym stanie.
Ostatnią rzeczą, o której będziemy mówić w tej sekcji, jest transpilacja. Transpilacja to proces przepisywania danego obwodu wejściowego w celu dopasowania go do fizycznych ograniczeń i Architektury Zestawu Instrukcji (ISA) konkretnego urządzenia kwantowego. Podobnie jak klasyczne kompilatory, oznacza to tłumaczenie abstrakcyjnych operacji unitarnych na natywny zestaw bramek, który docelowe urządzenie może wykonać. Optymalizuje również instrukcje obwodu pod kątem wydajnego wykonania na zaszumionych komputerach kwantowych, przy czym procedura stopniowo zmienia strukturę obwodu, stosując kilka etapów optymalizacji.
Check your understanding
Ile qubitów jest w poniższym obwodzie?

Odpowiedź:
Cztery.
Check your understanding
Załóżmy, że modelujesz elektrony w cząsteczce. Chcesz przybliżyć (a) energię stanu podstawowego cząsteczki oraz (b) które stany bazy obliczeniowej dominują w stanie podstawowym cząsteczki. W każdym przypadku, czy użyłbyś prymitywu Estimator, czy Sampler?
Odpowiedź:
(a) Estimator (b) Sampler
Classical programming models
Istnieje wiele modeli programowania dla klasycznych komputerów, ale w tej sekcji skupimy się na dwóch najpopularniejszych: programowaniu równoległym i przepływach zadań. Używając tych dwóch modeli razem z kwantowymi modelami programowania, można wyrazić niemal każdy hybrydowy przepływ pracy kwantowo-klasyczny o dowolnym stopniu złożoności.
Parallel programming
Programowanie równoległe to model, który dzieli program na podproblemy, które można wykonywać jednocześnie. Istnieją dwa główne paradygmaty programowania równoległego:
-
Równoległość ze wspólną pamięcią (Open Multiprocessing, czyli OpenMP): Używana do wykorzystania wielu rdzeni w obrębie jednego węzła obliczeniowego. Wątki wykonania współdzielą jedną przestrzeń pamięci.
-
Równoległość z rozproszoną pamięcią (Message Passing Interface, czyli MPI): Używana do skalowania na wiele oddzielnych węzłów obliczeniowych. Każdy proces ma swoją własną izolowaną przestrzeń pamięci.
Tutaj skupimy się na modelu rozproszonej pamięci, ponieważ jest on niezbędny w superkomputerach wielowęzłowych oraz przy koordynowaniu wielkoskalowych heterogenicznych zadań kwantowo-klasycznych.
Istnieje kilka koncepcji, które musimy zrozumieć, aby pracować w modelu programowania równoległego z rozproszoną pamięcią:
- Proces — niezależna instancja programu z własną przestrzenią pamięci.
- Ranga — unikalny identyfikator całkowitoliczbowy przypisany do każdego procesu, używany specjalnie do identyfikacji nadawcy i odbiorcy podczas komunikacji (niekoniecznie „ranga" w sensie hierarchii priorytetów).
- Synchronizacja — mechanizm koordynacji między różnymi rangami i procesami.
- Jeden program, wiele danych (SPMD) — abstrakcyjny model obliczeniowy, w którym jedna instancja kodu źródłowego działa jednocześnie na wielu procesach, z których każdy operuje na innym podzbiorze całości danych.
- Przekazywanie komunikatów — paradygmat komunikacji stosowany w architekturach z rozproszoną pamięcią, który pozwala niezależnym procesom wymieniać dane i wyniki pośrednie. Opiera się na jawnych operacjach „wysyłania" i „odbierania" w celu koordynacji wykonania między różnymi węzłami obliczeniowymi.
Istnieje standard o nazwie MPI, który implementuje ten paradygmat przekazywania komunikatów dla architektur równoległych. MPI stanowi funkcjonalne ucieleśnienie wszystkich powyższych koncepcji, dostarczając konkretne wywołania biblioteczne niezbędne do zarządzania procesami, przypisywania rang, ułatwiania synchronizacji i umożliwiania przekazywania komunikatów w modelu SPMD. Ł ącząc wszystkie te koncepcje, możemy powiedzieć, że wykonanie programu równoległego przebiega w następujący sposób:
- Jeden skompilowany program (ten sam plik binarny) jest kopiowany i uruchamiany przez program uruchamiający zadania, tworząc wiele równoległych procesów na wielu węzłach.
- Główny przepływ sterowania programu jest dyktowany przez rangę procesu. To właśnie jest zasada SPMD w działaniu: program używa logiki warunkowej (na przykład
if (rank == 0)) aby zapewnić, że tylko określone, zrównoleglone sekcje kodu są wykonywane przez procesy robocze, podczas gdy proces nadrzędny (często Ranga 0) obsługuje inicjalizację i końcową agregację. - Komunikacja między procesami odbywa się poprzez przekazywanie komunikatów (z użyciem MPI), wywoływane za każdym razem, gdy proces musi wymienić dane lub wyniki pośrednie z inną rangą.
Wizualnie wygląda to mniej więcej tak:
Spróbujmy zastosować niektóre z właśnie poznanych koncepcji w kodzie.
Najpierw spróbujemy uruchomić prosty równoległy program „hello world" używając OpenMPI, który jest implementacją protokołu MPI — standardu przekazywania komunikatów w programowaniu równoległym. Tutaj użyjemy pakietu Pythona mpi4py, który jest powiązaniem Pythona ze standardem Message Passing Interface (MPI).
$ 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")
~
~
Do uruchomienia tego programu użyjemy dwóch węzłów, co określimy w naszym skrypcie zgłoszeniowym.
$ 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
Następnie uruchom skrypt powłoki.
$ sbatch mpi-hello-world.sh
Możemy sprawdzić logi wyników zadania.
$ 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}
Tutaj użyliśmy dwóch węzłów, a proces na każdym węźle jest teraz identyfikowany przez rangę — Ranga 0 i Ranga 1 — które służą do określania przepływu sterowania programem.
Task workflows
Porozmawiajmy teraz o modelu programowania opartym na potoku zadań. Potok zadań abstrahuje obliczenia do postaci skierowanego grafu acyklicznego (DAG). W tym grafie każdy węzeł reprezentuje konkretne zadanie lub proces, a krawędzie (strzałki łączące węzły) reprezentują zależności (danych i kolejności) między nimi. Harmonogram to komponent, który przypisuje zadania do zasobów i organizuje ich wykonanie.
Konkretnym przykładem modelu potoku zadań zastosowanego w obliczeniach kwantowych jest framework Qiskit patterns. Wzorzec Qiskit to ogólny framework zaprojektowany w celu rozkładu problemów dziedzinowych na sekwencję etapów, szczególnie w przypadku zadań kwantowych. Umożliwia to bezproblemową kompozycyjność nowych możliwości opracowywanych przez badaczy IBM Quantum® (i innych) oraz otwiera drogę do przyszłości, w której zadania obliczeniowe na komputerach kwantowych są realizowane przez wydajną, heterogeniczną infrastrukturę obliczeniową (CPU/GPU/QPU). Cztery kroki wzorca Qiskit to: mapowanie, optymalizacja, wykonanie i post-przetwarzanie — wszystkie zadania są wykonywane jedno po drugim w potoku. Jednak w przypadku potoków zadań nie jesteśmy ograniczeni do liniowej kolejności wykonywania i możemy uruchamiać zadania równolegle. Każde zadanie w potoku może być samo w sobie odrębnym zadaniem równoległym. Możesz więc dowolnie łączyć te modele, aby opisywać algorytmy o dowolnej złożoności, a menedżer obciążenia, taki jak Slurm, zajmie się ich obsługą.
Powyższy obraz ilustruje wzorzec Qiskit w działaniu. Potok ma strukturę grafu złożonego z czterech etapów. Ta rozgałęziona struktura jest zarządzana i wykonywana przez harmonogram. Na początkowym etapie problem jest mapowany do postaci wykonywalnej kwantowo (Circuit kwantowy). W kolejnym etapie ten Circuit kwantowy jest optymalizowany pod kątem konkretnego sprzętu kwantowego. Na obrazie jest to pokazane jako proces równoległy, co demonstruje, jak jednocześnie można stosować wiele strategii optymalizacji. Zoptymalizowany Circuit kwantowy jest następnie wykonywany na rzeczywistym sprzęcie kwantowym. To trzeci etap obrazu, na którym harmonogram współpracuje z jedną fioletową jednostką przetwarzania kwantowego. Na koniec wyniki są post-przetwarzane przez zasoby klasyczne.
Why both?
Po co nam zatem zarówno programowanie równoległe, jak i potoki zadań? Przy całej dyskusji o równoległości kwantowej warto wyjaśnić, że nie wszystko w obliczeniach kwantowych jest równoległe.
Poprzednia lekcja na temat potoku SQD wspominała o procesach, których nie można zrównoleglić. Na przykład potrzebujemy wyników wielu pomiarów kwantowych, aby zrzutować naszą macierz na podprzestrzeń o wymiarze, który można efektywnie przetworzyć. Z kolei potrzebujemy zdiagonalizowanej macierzy i powiązanych wektorów stanu, aby sprawdzić spójność samoistną pomiarów kwantowych (używając na przykład zasady zachowania ładunku). Po tym wszystkim musimy zdecydować, czy energia stanu podstawowego dostatecznie zbiegła się do naszych celów. Te kroki są z konieczności sekwencyjne i wymagają testowania warunków zbieżności i spójności samoistnej przed przejściem do kolejnych etapów.
Do tego potoku wrócimy bardziej szczegółowo i zaimplementujemy go w następnej sekcji. Jedyne, co powinieneś wynieść z tej sekcji, to fakt, że potoki zadań są niezbędne.
Programming practice
Piękno modeli programowania polega na tym, że można je dowolnie łączyć. Znając kwantowe i klasyczne modele programowania, możesz opisać heterogeniczne obliczenia o dowolnej złożoności i wykonać je na sprzęcie. Przećwiczmy to na małym przykładzie połączonego potoku, który implementuje wzorzec Qiskit (mapuj, optymalizuj, wykonaj i post-przetwórz) w ramach Slurma, którego nauczyliśmy się w poprzednim rozdziale. Każde z czterech zadań będzie oddzielnym zadaniem Slurm, każde z własnymi zasobami. Zadanie optymalizacji użyje MPI do równoległej optymalizacji Circuitów (wyłącznie dla przykładu, jak na powyższym obrazie). Zadanie wykonania użyje zasobów kwantowych i kwantowych modeli programowania (Circuit i sampler). Ostatnie zadanie — post-przetwarzanie — znów użyje MPI równolegle z zasobami klasycznymi.
Mapping
Program mapping.py jest zaprojektowany do budowania Circuitu PauliTwoDesign, który jest powszechnie stosowany w literaturze dotyczącej kwantowego uczenia maszynowego oraz benchmarkingu kwantowego, z prostą obserwowalną mierzącą Qubit w kierunku systemu -qubitowego z losowymi parametrami początkowymi. Każdy z tych elementów (Circuit kwantowy przekonwertowany do pliku qasm, obserwowalana i parametry) zostanie zapisany do osobnego pliku w katalogu danych i będzie używany jako dane wejściowe na etapie optymalizacji.
Skrypt powłoki dla tego etapu (mapping.sh) to
#!/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
który definiuje nazwę zadania, format wyjścia oraz liczbę węzłów/zadań/CPU.
Optimization
Program optimization.py rozpoczyna się od pobrania plików z etapu mapowania. Tutaj użyjesz QRMI, aby wprowadzić zasoby kwantowe do tego programu.
qrmi = QRMI()
resources = qrmi.resources()
quantum_resource = resources[0]
...
Następnie wykonuje lekką optymalizację, ustawiając optimization_level=1 w celu transpilacji Circuitu kwantowego i zastosowania układu Circuitu do obserwowalnej, a następnie zapisuje je do folderu danych.
Skrypt powłoki dla tego etapu (optimization.sh) to
#!/bin/bash
#SBATCH --job-name=optimization
#SBATCH --output=output/optimization.out
#SBATCH --ntasks=4
#SBATCH --partition=classical
srun python3 /tmp/optimization.py
Tutaj --ntasks=4 żąda czterech klasycznych zadań od Slurma dla procesu równoległego.
Execution
To jest główny etap kwantowy, na którym zoptymalizowany Circuit kwantowy z poprzedniego kroku jest uruchamiany na QPU przez Estimator. W tym celu najpierw pobierzemy trzy pliki — transpilowany Circuit kwantowy, obserwowalną i parametry początkowe — a następnie przekażemy je do Estimator. Zwraca on szacowaną wartość obserwowalnej i ją wyświetla.
Skrypt execution.sh korzysta z wtyczki Slurm, aby użyć zasobu kwantowego.
#!/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
Post-processing
Etap post-przetwarzania często obejmuje klasyczną diagonalizację i sprawdzanie spójności samoistnej. Może być również iteracyjny. Najlepiej omówić etap post-przetwarzania w następnej lekcji, w której jasny jest kontekst fizyczny i cel kroków iteracyjnych.
Combining it all together
Możemy połączyć wszystkie te zadania w potok, używając argumentu dependency dla polecenia sbatch:
$ 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)
Możemy też sprawdzić kolejkę wykonania Slurma.
$ 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)
Był to przykład-zabawka demonstrujący mieszanie modeli programowania. W następnym rozdziale przyjrzymy się algorytmom ze świata rzeczywistego i zaprezentujemy modele programowania oraz zarządzanie zasobami na użytecznych potokach.
Summary
W tej lekcji zademonstrowano, jak łączyć wiele klasycznych i kwantowych modeli programowania, aby budować, zarządzać i wykonywać kompletny potok czterech etapów. Zaczęliśmy od podstawowych koncepcji Circuitów kwantowych i prymitywów, a następnie zbadaliśmy klasyczne modele, takie jak programowanie równoległe i potoki zadań. Łącząc wszystkie koncepcje, zbudowaliśmy wzorzec Qiskit — mapuj, optymalizuj, wykonaj i post-przetwórz — zarządzany przez menedżer obciążenia Slurm z prostym Circuitem kwantowym i obserwowalną.
W następnej lekcji użyjemy tego frameworku do uruchamiania opartych na próbkowaniu algorytmów kwantowych, pokazując, jak ten potok można zastosować do rozwiązywania rzeczywistych problemów.
Cały kod i skrypty użyte w tym rozdziale są dostępne dla ciebie w tym repozytorium Github.