Modele de programare
Modelele de programare sunt specificații fundamentale care definesc modul în care software-ul este structurat și executat. Ele oferă un cadru pentru dezvoltatori prin care pot exprima algoritmi și organiza codul, abstractizând adesea detaliile de nivel scăzut ale hardware-ului sau mediului de execuție. Diferite modele sunt potrivite pentru diferite tipuri de probleme și arhitecturi hardware, oferind niveluri variate de abstractizare și control.
În această lecție vom trece în revistă modelele de programare cuantică și clasică și vom vedea cum le putem combina pentru a opera algoritmi în medii eterogene. Iskandar Sitdikov ne oferă o prezentare generală în videoclipul următor.
Modelul de programare pentru QPU-uri
Vom începe cu modelul de programare pentru calculatoarele cuantice. Modelul fundamental de programare, familiar aproape tuturor dezvoltatorilor de software cuantic, este circuitul cuantic. Nu vom intra în detaliile modelului circuitului cuantic aici, deoarece avem deja o excelentă prelegere susținută de John Watrous care explică acest lucru în detaliu. Vom menționa doar că circuitul este construit dintr-un set de linii (numite fire) care reprezintă qubiți, Gate-uri care reprezintă operații pe stări cuantice și un set de măsurători.
Un alt concept important de model de programare pentru calculul cuantic este ceea ce numim primitive computaționale. Aceste primitive reprezintă unele dintre cele mai frecvente sarcini pe care utilizatorii doresc să le realizeze cu un calculator cuantic. În prezent sunt disponibile mai multe primitive, inclusiv Executor. În acest curs ne vom concentra în primul rând pe primitivele Sampler și Estimator. Sampler îți oferă posibilitatea de a eșantiona o stare pregătită de circuitul tău cuantic. Îți spune ce stări din baza computațională alcătuiesc starea cuantică pregătită pe circuitul tău cuantic. Estimator îți permite să estimezi valoarea de așteptare a unui observabil pentru un sistem aflat în starea pregătită de circuitul tău cuantic. Un context frecvent este estimarea energiei unui sistem într-o stare specifică.
Ultimul lucru despre care vom vorbi în această secțiune este transpilarea. Transpilarea este procesul de rescriere a unui circuit de intrare dat pentru a se potrivi constrângerilor fizice și Arhitecturii Setului de Instrucțiuni (ISA) ale unui dispozitiv cuantic specific. Similar compilatoarelor clasice, aceasta înseamnă traducerea operațiilor unitare abstracte în setul nativ de Gate-uri pe care dispozitivul țintă le poate executa. De asemenea, optimizează instrucțiunile circuitului pentru o execuție eficientă pe calculatoarele cuantice cu zgomot, rutina modificând treptat structura circuitului prin aplicarea mai multor etape de optimizare.
Verifică-ți înțelegerea
Câți qubiți sunt în circuitul de mai jos?

Răspuns:
Patru.
Verifică-ți înțelegerea
Să presupunem că modelezi electronii dintr-o moleculă. Vrei să aproximezi (a) energia stării fundamentale a moleculei și (b) ce stări din baza computațională sunt dominante în starea fundamentală a moleculei. În fiecare caz, ai folosi primitiva Estimator sau Sampler?
Răspuns:
(a) Estimator (b) Sampler
Modele de programare clasice
Există multe modele de programare pentru calculatoarele clasice, dar în această secțiune ne vom concentra pe două dintre cele mai populare: programarea paralelă și fluxurile de sarcini. Folosind aceste două modele alături de modelele de programare cuantică, poți exprima aproape orice flux de lucru hibrid cuantic-clasic, de orice complexitate.
Programare paralelă
Programarea paralelă este un model care împarte un program în subprobleme ce pot fi executate simultan. Există două paradigme principale ale programării paralele:
-
Paralelism cu memorie partajată (Open Multiprocessing, sau OpenMP): Folosit pentru a exploata mai multe nuclee dintr-un singur nod de calcul. Firele de execuție partajează un singur spațiu de memorie.
-
Paralelism cu memorie distribuită (Message Passing Interface, sau MPI): Folosit pentru scalarea pe mai multe noduri de calcul separate. Fiecare proces are propriul spațiu de memorie izolat.
Aici ne vom concentra pe modelul cu memorie distribuită, deoarece este esențial pentru supercomputerele multi-nod și pentru coordonarea joburilor eterogene cuantic-clasice la scară largă.
Există câteva concepte pe care trebuie să le înțelegem pentru a opera în modele de programare paralelă cu memorie distribuită:
- Proces - O instanță independentă a programului cu propriul spațiu de memorie.
- Rang - Un identificator întreg unic atribuit fiecărui proces, folosit în special pentru a identifica expeditorul și destinatarul în timpul comunicării (nu neapărat un „rang" în sensul de prioritizare).
- Sincronizare - Un mecanism de coordonare între diferite ranguri și procese.
- Un singur program, date multiple (SPMD) - Un model computațional abstract în care o singură instanță de cod sursă rulează simultan pe mai multe procese, fiecare operând pe un subset diferit din totalul datelor.
- Transmitere de mesaje - Paradigma de comunicare folosită în arhitecturile cu memorie distribuită, care permite proceselor independente să schimbe date și rezultate intermediare. Se bazează pe operații explicite de „trimitere" și „recepție" pentru a coordona execuția între diferite noduri de calcul.
Există un standard numit MPI care implementează această paradigmă de transmitere a mesajelor pentru arhitecturile paralele. MPI servește drept întruchipare funcțională a tuturor conceptelor enumerate mai sus, furnizând apelurile specifice de bibliotecă necesare pentru gestionarea proceselor, atribuirea rangurilor, facilitarea sincronizării și activarea transmiterii de mesaje sub modelul SPMD. Reunind toate aceste concepte, putem spune că execuția unui program paralel se desfășoară în felul următor:
- Un singur program compilat (același fișier binar) este copiat și executat de un lansator de joburi pentru a crea mai multe procese paralele pe mai multe noduri.
- Fluxul principal de control al programului este dictat de rangul procesului. Acesta este principiul SPMD în acțiune: programul folosește logica condiționată (de exemplu,
if (rank == 0)) pentru a se asigura că doar anumite secțiuni paralelizate ale codului sunt executate de procesele worker, în timp ce un proces master (adesea Rang 0) se ocupă de inițializare și agregarea finală. - Comunicarea între procese are loc prin transmitere de mesaje (folosind MPI), care este apelată ori de câte ori un proces trebuie să schimbe date sau rezultate intermediare cu un alt rang.
Vizual, va arăta cam așa:
Să încercăm să aplicăm câteva din conceptele pe care tocmai le-am învățat în cod.
În primul rând, vom încerca să rulăm un program paralel simplu de tip „hello world" folosind OpenMPI, care este o implementare a protocolului MPI, un standard pentru transmiterea mesajelor în programarea paralelă. Aici vom folosi pachetul Python mpi4py, care este un binding Python pentru standardul 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")
~
~
Vom folosi două noduri pentru a rula acest program, pe care le vom specifica în scriptul nostru de trimitere.
$ 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
Apoi rulează scriptul shell.
$ sbatch mpi-hello-world.sh
Putem verifica jurnalele de rezultate ale jobului.
$ 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}
Aici am folosit două noduri și procesul de pe fiecare nod este acum identificat printr-un rang — Rang 0 și Rang 1 — care sunt folosite pentru a decide fluxul de control al programului.
Fluxuri de sarcini
Acum să vorbim despre modelul de programare pentru fluxurile de sarcini. Un flux de sarcini abstractizează computația într-un graf aciclic orientat (DAG). În acest graf, fiecare nod reprezintă o sarcină sau un job particular, iar muchiile (săgețile care conectează nodurile) reprezintă dependențele (de date și de ordine) dintre ele. Un planificator este componenta care mapează sarcinile la resurse și orchestrează execuția.
Un exemplu concret de model de flux de sarcini aplicat calculului cuantic este cadrul Qiskit patterns. Un Qiskit pattern este un cadru general conceput pentru a descompune problemele specifice domeniului într-o secvență de etape, mai ales pentru sarcinile cuantice. Aceasta permite compozabilitatea perfectă a noilor capabilități dezvoltate de cercetătorii IBM Quantum® (și de alții) și permite un viitor în care sarcinile de calcul cuantic sunt realizate de o infrastructură de calcul eterogenă (CPU/GPU/QPU) puternică. Cele patru etape ale unui Qiskit pattern sunt maparea, optimizarea, execuția și post-procesarea, unde toate sarcinile sunt executate una după alta într-un pipeline. Dar cu fluxurile de sarcini nu suntem legați de o ordine de execuție liniară și putem executa sarcini în paralel. Fiecare sarcină a unui flux poate fi ea însăși un întreg job paralel. Deci poți combina aceste modele pentru a descrie algoritmi de complexitate arbitrară, iar un manager de sarcini precum SLURM le va gestiona.
Imaginea de mai sus ilustrează Qiskit pattern în acțiune. Fluxul de lucru are o structură de graf cu patru etape. Această structură ramificată este orchestrată și executată de planificator. Problema este mapată în formă executabilă cuantic (circuit cuantic) în etapa inițială. În etapa următoare, acest circuit cuantic este optimizat pentru hardware-ul cuantic specific. Imaginea arată aceasta ca un proces paralel, demonstrând cum mai multe strategii de optimizare ar putea fi aplicate simultan. Circuitul cuantic optimizat este apoi executat pe hardware-ul cuantic real. Aceasta este a treia etapă a imaginii, în care planificatorul lucrează cu o unitate de procesare cuantică violet. În final, rezultatele sunt post-procesate de resursele clasice.
De ce ambele?
Deci de ce avem nevoie atât de programarea paralelă, cât și de fluxurile de sarcini? Cu toată discuția despre paralelismul cuantic, merită să clarificăm că nu totul este paralel în calculul cuantic.
Lecția anterioară despre fluxul de lucru SQD a menționat câteva procese care nu pot fi paralelizate. De exemplu, avem nevoie de rezultatele multor măsurători cuantice pentru a proiecta matricea noastră într-un subspațiu de dimensiune gestionabilă. La rândul lui, avem nevoie de matricea diagonalizată și de vectorii de stare asociați pentru a verifica auto-consistența măsurătorilor cuantice (folosind, de exemplu, conservarea sarcinii). După toate acestea, trebuie să decidem dacă energia stării fundamentale a conversat suficient pentru scopurile noastre. Acești pași sunt în mod necesar secvențiali și necesită testarea condițiilor de convergență și auto-consistență înainte de a continua.
Acest flux de lucru va fi revizuit mai detaliat și implementat în secțiunea următoare. Singurul lucru pe care trebuie să-l reții din această secțiune este că fluxurile de sarcini sunt necesare.
Practică de programare
Frumusețea modelelor de programare este că le poți combina pe toate împreună. Cunoscând modelele de programare cuantică și clasică, poți descrie o computație eterogenă de complexitate arbitrară și o poți executa pe hardware. Să exersăm aceasta cu un mic exemplu de flux de lucru combinat, care implementează Qiskit pattern (map, optimize, execute și post-process) în SLURM, pe care l-am învățat în ultimul capitol. Fiecare dintre cele patru sarcini va fi un job SLURM separat, fiecare cu propriile resurse. Sarcina de optimizare va folosi MPI pentru a optimiza circuitele în paralel (doar ca exemplu, ca în imaginea de mai sus). Sarcina de execuție va folosi resurse cuantice și modele de programare cuantică (Circuit și Sampler). Ultima sarcină — post-procesarea — va folosi din nou MPI în paralel cu resursele clasice.
Mapare
Programul mapping.py este conceput pentru a construi un circuit PauliTwoDesign, care este frecvent folosit în literatura de machine learning cuantic și în literatura de benchmark cuantic, cu un observabil simplu care măsoară qubit-ul în direcția a unui sistem cu qubiți, cu parametri inițiali aleatori. Fiecare dintre acestea (circuitul cuantic convertit într-un fișier qasm, observabilul și parametrii) va fi salvat într-un fișier separat în directorul de date și va fi folosit ca intrare în etapa de optimizare.
Scriptul shell al acestei etape (mapping.sh) este
#!/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
care definește numele jobului, formatul de ieșire și numărul de noduri/sarcini/CPU-uri.
Optimizare
Programul optimization.py începe prin a aduce fișierele din etapa de mapare. Aici vei folosi QRMI pentru a aduce resurse cuantice în acest program.
qrmi = QRMI()
resources = qrmi.resources()
quantum_resource = resources[0]
...
Apoi efectuează o optimizare ușoară setând optimization_level=1 pentru a transpila circuitul cuantic și a aplica layout-ul circuitului asupra observabilului, după care le salvează în folderul de date.
Scriptul shell al acestei etape (optimization.sh) este
#!/bin/bash
#SBATCH --job-name=optimization
#SBATCH --output=output/optimization.out
#SBATCH --ntasks=4
#SBATCH --partition=classical
srun python3 /tmp/optimization.py
Aici --ntasks=4 solicită patru sarcini clasice din SLURM pentru un proces paralel.
Execuție
Aceasta este etapa cuantică principală în care circuitul cuantic optimizat din pasul anterior este rulat pe QPU de către Estimator. Pentru a face aceasta, mai întâi vom aduce trei fișiere — circuitul cuantic transpilat, observabilul și parametrii inițiali — apoi le vom transmite la Estimator. Acesta produce valoarea estimată a observabilului și o afișează.
Scriptul execution.sh folosește un plugin SLURM pentru a utiliza o resursă cuantică.
#!/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-procesare
Etapa de post-procesare implică adesea diagonalizarea clasică și verificări de auto-consistență. Poate fi și iterativă. Este cel mai util să consideri etapa de post-procesare în lecția următoare, în care contextul fizic și scopul pașilor iterativi sunt clare.
Combinând totul împreună
Putem înlănțui toate aceste sarcini într-un flux de lucru folosind argumentul de dependență pentru comanda 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)
Și putem verifica coada de execuție SLURM.
$ 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)
Acesta a fost un exemplu demonstrativ pentru a ilustra mixul de modele de programare. În capitolul următor vom analiza algoritmi din lumea reală și vom demonstra modele de programare și gestionarea resurselor pe fluxuri de lucru utile.
Rezumat
În această lecție am demonstrat cum să combini mai multe modele de programare clasice și cuantice pentru a construi, gestiona și executa un flux de lucru complet în patru etape. Am început cu conceptele fundamentale ale circuitelor cuantice și primitivelor, apoi am explorat modelele clasice precum programarea paralelă și fluxurile de sarcini. Combinând toate conceptele, am construit un Qiskit pattern — map, optimize, execute și post-process — orchestrat de managerul de sarcini SLURM, cu un circuit cuantic simplu și un observabil.
În lecția următoare vom folosi acest cadru pentru a rula algoritmi cuantici bazați pe eșantionare, arătând cum acest flux de lucru poate fi aplicat pentru a rezolva probleme relevante.
Tot codul și scripturile folosite în acest capitol îți sunt disponibile în acest repository Github.