Vai al contenuto principale

Modelli di programmazione

I modelli di programmazione sono specifiche fondamentali che definiscono come il software viene strutturato ed eseguito. Forniscono un framework agli sviluppatori per esprimere algoritmi e organizzare il codice, astraendo spesso i dettagli di basso livello dell'hardware sottostante o dell'ambiente di esecuzione. Modelli diversi si adattano a tipologie di problemi e architetture hardware differenti, offrendo vari livelli di astrazione e controllo.

In questa lezione esamineremo i modelli di programmazione quantistici e classici e vedremo come combinarli per far operare algoritmi in ambienti eterogenei. Iskandar Sitdikov ci offre una panoramica nel video seguente.

Modello di programmazione per i QPU​

Iniziamo con il modello di programmazione per i computer quantistici. Il modello di programmazione fondamentale, familiare a quasi tutti gli sviluppatori quantistici, è il circuito quantistico. Non entreremo nei dettagli del modello a circuito quantistico in questa sede, poiché esiste già un'ottima lezione di John Watrous che lo spiega in modo approfondito. Ci limiteremo a ricordare che il circuito è composto da un insieme di linee (chiamate "wire") che rappresentano i qubit, da gate che rappresentano operazioni sugli stati quantistici e da un insieme di misurazioni.

A quantum circuit diagram showing qubits as horizontal lines and quantum gates as boxes or connections between qubits.

Un altro concetto importante del modello di programmazione per il calcolo quantistico è quello delle primitive computazionali. Queste primitive rappresentano alcuni dei task più comuni che gli utenti vogliono realizzare con un computer quantistico. Al momento sono disponibili diverse primitive, tra cui Executor. In questo corso ci concentreremo principalmente sulle primitive Sampler ed Estimator. Sampler ti dà la possibilità di campionare uno stato preparato dal tuo circuito quantistico: indica quali stati della base computazionale compongono lo stato quantistico preparato sul tuo circuito. Estimator ti permette di stimare il valore di aspettazione di un'osservabile per un sistema nello stato preparato dal tuo circuito quantistico. Un contesto comune è la stima dell'energia di un sistema in uno stato specifico.

A model histogram of results from sampler. Some states are very likely to be measured, others are very unlikely.

L'ultimo argomento di questa sezione è la traspirazione. La traspirazione è il processo di riscrittura di un circuito di input in modo da soddisfare i vincoli fisici e l'Instruction Set Architecture (ISA) di uno specifico dispositivo quantistico. Come i compilatori classici, questo significa tradurre operazioni unitarie astratte nel set di gate nativi che il dispositivo target può eseguire. Ottimizza inoltre le istruzioni del circuito per un'esecuzione efficiente su computer quantistici rumorosi, attraverso una procedura che modifica progressivamente la struttura del circuito applicando diverse fasi di ottimizzazione.

A diagram of transpilation showing how an abstract circuit is mapped into an instruction set architecture circuit. That is, the circuit is rewritten using the native gates and connectivity of the target hardware.

Verifica la comprensione​

Quanti qubit contiene il circuito qui sotto? A circuit diagram with four horizontal lines and many gates.

Risposta:

Quattro.

Verifica la comprensione​

Supponi di modellare gli elettroni in una molecola. Vuoi approssimare (a) l'energia dello stato fondamentale della molecola, e (b) quali stati della base computazionale sono più dominanti nello stato fondamentale della molecola. In ciascun caso, useresti la primitiva Estimator o Sampler?

Risposta:

(a) Estimator (b) Sampler

Modelli di programmazione classici​

Esistono molti modelli di programmazione per i computer classici, ma in questa sezione ci concentreremo sui due più diffusi: la programmazione parallela e i workflow a task. Usando questi due modelli insieme ai modelli di programmazione quantistici, è possibile esprimere quasi qualsiasi workflow ibrido quantistico-classico, indipendentemente dalla complessità.

Programmazione parallela​

La programmazione parallela è un modello che suddivide un programma in sotto-problemi che possono essere eseguiti simultaneamente. Esistono due paradigmi principali di programmazione parallela:

  • Parallelismo a memoria condivisa (Open Multiprocessing, o OpenMP): usato per sfruttare più core all'interno di un singolo nodo di calcolo. I thread di esecuzione condividono un unico spazio di memoria.

  • Parallelismo a memoria distribuita (Message Passing Interface, o MPI): usato per scalare su più nodi di calcolo separati. Ogni processo ha il proprio spazio di memoria isolato.

In questa sede ci concentreremo sul modello a memoria distribuita, poiché è essenziale per il supercalcolo multi-nodo e per il coordinamento di job ibridi quantistico-classici su larga scala.

Per operare con i modelli di programmazione parallela a memoria distribuita, dobbiamo comprendere alcuni concetti fondamentali:

  • Processo — Un'istanza indipendente del programma con il proprio spazio di memoria.
  • Rank — Un identificatore intero univoco assegnato a ciascun processo, usato specificamente per identificare mittente e destinatario durante la comunicazione (non necessariamente un "rango" nel senso di priorità).
  • Sincronizzazione — Un meccanismo per il coordinamento tra rank e processi diversi.
  • Single program, multiple data (SPMD) — Un modello computazionale astratto in cui un'unica istanza del codice sorgente viene eseguita simultaneamente su più processi, ognuno dei quali opera su un sottoinsieme diverso dei dati totali.
  • Message passing — Il paradigma di comunicazione usato nelle architetture a memoria distribuita, che consente a processi indipendenti di scambiarsi dati e risultati intermedi. Si basa su operazioni esplicite di "send" e "receive" per coordinare l'esecuzione tra diversi nodi di calcolo.

Esiste uno standard chiamato MPI che implementa questo paradigma di message passing per le architetture parallele. MPI rappresenta la concretizzazione funzionale di tutti i concetti elencati sopra, fornendo le chiamate di libreria specifiche necessarie per gestire i processi, assegnare i rank, facilitare la sincronizzazione e abilitare il message passing nel modello SPMD. Mettendo insieme tutti questi concetti, possiamo dire che l'esecuzione di un programma parallelo avviene nel seguente modo:

  • Un singolo programma compilato (lo stesso file binario) viene copiato e avviato da un job launcher per creare più processi paralleli su più nodi.
  • Il flusso di controllo principale del programma è determinato dal rank del processo. Questo è il principio SPMD in azione: il programma usa una logica condizionale (ad esempio, if (rank == 0)) per fare in modo che solo determinate sezioni parallelizzate del codice vengano eseguite dai processi worker, mentre un processo master (spesso Rank 0) gestisce l'inizializzazione e l'aggregazione finale.
  • La comunicazione tra i processi avviene tramite message passing (usando MPI), che viene invocato ogni volta che un processo deve scambiare dati o risultati intermedi con un altro rank.

Visivamente, apparirà qualcosa di simile a questo:

A diagram of a task being divided between nodes.

Proviamo ad applicare alcuni dei concetti appena appresi al codice.

Per prima cosa, proveremo a eseguire un semplice programma parallelo "hello world" usando OpenMPI, che è un'implementazione del protocollo MPI, uno standard per il message passing nella programmazione parallela. Utilizzeremo il pacchetto Python mpi4py, che è un binding Python per lo standard 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")

~
~

Useremo due nodi per eseguire questo programma, che specificheremo nel nostro script di submission.

$ 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

Poi esegui lo script shell.

$ sbatch mpi-hello-world.sh

Possiamo controllare i log dei risultati del job.

$ 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}

In questo esempio abbiamo usato due nodi e il processo su ciascun nodo è ora identificato da un rank — Rank 0 e Rank 1 — che vengono usati per decidere il flusso di controllo del programma.

Workflow a task​

Parliamo ora del modello di programmazione a workflow a task. Un task workflow astrae il calcolo in un grafo aciclico diretto (DAG). In questo grafo, ogni nodo rappresenta un particolare task o job, mentre gli archi (le frecce che collegano i nodi) rappresentano le dipendenze (dati e ordinamento) tra di essi. Uno scheduler è il componente che mappa i task sulle risorse e orchestra l'esecuzione.

Un esempio concreto di un modello a workflow a task applicato al calcolo quantistico è il framework dei Qiskit patterns. Un Qiskit pattern è un framework generale progettato per scomporre problemi specifici di dominio in una sequenza di fasi, in particolare per task quantistici. Questo consente la componibilità senza soluzione di continuità di nuove funzionalità sviluppate dai ricercatori di IBM Quantum® (e non solo) e apre la strada a un futuro in cui i task di calcolo quantistico vengono eseguiti da un'infrastruttura di calcolo eterogenea (CPU/GPU/QPU) e potente. I quattro passi di un Qiskit pattern sono: mappatura, ottimizzazione, esecuzione e post-elaborazione, dove tutti i task vengono eseguiti uno dopo l'altro in una pipeline. Ma con i workflow a task non siamo vincolati a un ordine di esecuzione lineare e possiamo eseguire task in parallelo. Ogni task di un workflow può essere un intero job parallelo a sé stante. Puoi quindi combinare questi modelli a piacimento per descrivere algoritmi di complessità arbitraria, e un workload manager come Slurm si occuperà di gestirli.

A diagram of computing tasks organized into a workflow in which some processes are executed in parallel and others in sequence.

L'immagine sopra illustra il Qiskit pattern in azione. Il workflow ha una struttura a grafo con quattro fasi. Questa struttura ramificata è orchestrata ed eseguita dallo scheduler. Il problema viene mappato in forma eseguibile quantisticamente (circuito quantistico) nella fase iniziale. Nella fase successiva, questo circuito quantistico viene ottimizzato per l'hardware quantistico specifico. L'immagine mostra questo come un processo parallelo, dimostrando come possano essere applicate simultaneamente più strategie di ottimizzazione. Il circuito quantistico ottimizzato viene poi eseguito sull'hardware quantistico reale. Questa è la terza fase dell'immagine, in cui lo scheduler lavora con una singola unità di elaborazione quantistica (QPU) viola. Infine, i risultati vengono post-elaborati dalle risorse classiche.

Perché entrambi?​

Quindi perché abbiamo bisogno sia della programmazione parallela che dei workflow a task? A dispetto di tutto il parlare di parallelismo quantistico, vale la pena chiarire che non tutto è parallelo nel calcolo quantistico.

La lezione precedente sul workflow SQD ha menzionato alcuni processi che non possono essere parallelizzati. Ad esempio, abbiamo bisogno dei risultati di molte misurazioni quantistiche per proiettare la nostra matrice in un sottospazio di dimensione trattabile. A sua volta, abbiamo bisogno della matrice diagonalizzata e dei vettori di stato associati per verificare la coerenza delle misurazioni quantistiche (usando, ad esempio, la conservazione della carica). Dopo tutto ciò, dobbiamo decidere se l'energia dello stato fondamentale è conversa a sufficienza per i nostri scopi. Questi passaggi sono necessariamente sequenziali e richiedono la verifica delle condizioni di convergenza e auto-consistenza prima di procedere.

A schematic of the workflow specific to sample-based quantum diagonalization. The steps include a variational quantum circuit, using measurements to project the Hamiltonian into a subspace, then using a classical optimizer to update variational parameters in the circuit and repeating.

Questo workflow verrà ripreso in modo più dettagliato e implementato nella sezione successiva. L'unica cosa che devi ricavare da questa sezione è che i workflow a task sono necessari.

Pratica di programmazione​

La bellezza dei modelli di programmazione sta nel fatto che puoi combinarli tutti insieme. Conoscendo i modelli di programmazione quantistici e classici, puoi descrivere un calcolo eterogeneo di complessità arbitraria ed eseguirlo su hardware reale. Pratichiamo questo con un piccolo esempio di workflow combinato, che implementa il Qiskit pattern (mappa, ottimizza, esegui e post-elabora) all'interno di Slurm, come abbiamo imparato nell'ultimo capitolo. Ognuno dei quattro task sarà un job Slurm separato, ciascuno con le proprie risorse. Il task di ottimizzazione userà MPI per ottimizzare i circuiti in parallelo (solo a scopo dimostrativo, come nell'immagine precedente). Il task di esecuzione userà risorse quantistiche e modelli di programmazione quantistici (circuito e Sampler). L'ultimo task — la post-elaborazione — utilizzerà nuovamente MPI in parallelo con risorse classiche.

Mappatura​

Il programma mapping.py è progettato per costruire un circuito PauliTwoDesign, frequentemente usato nella letteratura sul machine learning quantistico e sui benchmark quantistici, con una semplice osservabile che misura il qubit (n−1)th(n-1)^\text{th} nella direzione ZZ di un sistema a nn qubit con parametri iniziali casuali. Ognuno di questi elementi (il circuito quantistico convertito in un file qasm, l'osservabile e i parametri) verrà salvato in un file separato nella directory dei dati e sarà usato come input nella fase di ottimizzazione.

Lo script shell di questa fase (mapping.sh) è

#!/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

che definisce il nome del job, il formato di output e il numero di nodi/task/CPU.

Ottimizzazione​

Il programma optimization.py inizia caricando i file dalla fase di mappatura. Qui userai QRMI per portare le risorse quantistiche in questo programma.

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

Esegue poi una leggera ottimizzazione impostando optimization_level=1 per traspilare il circuito quantistico e applicare il layout del circuito all'osservabile, per poi salvarli nella cartella dei dati.

Lo script shell di questa fase (optimization.sh) è

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

srun python3 /tmp/optimization.py

Qui --ntasks=4 richiede a Slurm quattro task classici per un processo parallelo.

Esecuzione​

Questa è la fase quantistica centrale, in cui il circuito quantistico ottimizzato dal passo precedente viene eseguito sul QPU tramite Estimator. Per farlo, caricheremo prima tre file — il circuito quantistico traspilato, l'osservabile e i parametri iniziali — per poi passarli a Estimator. Questo restituisce il valore stimato dell'osservabile e lo stampa.

Lo script execution.sh sfrutta un plugin Slurm per usare una risorsa quantistica.

#!/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-elaborazione​

Il passo di post-elaborazione spesso comprende la diagonalizzazione classica e verifiche di auto-consistenza. Può anche essere iterativo. È più utile esaminare il passo di post-elaborazione nella prossima lezione, in cui il contesto fisico e lo scopo dei passi iterativi sono chiari.

Combinare tutto insieme​

Possiamo concatenare tutti questi task in un workflow usando l'argomento di dipendenza del comando 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)

E possiamo controllare la coda di esecuzione di 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)

Questo era un esempio didattico per dimostrare la combinazione di modelli di programmazione. Nel prossimo capitolo esamineremo algoritmi del mondo reale e dimostreremo i modelli di programmazione e la gestione delle risorse su workflow utili.

Riepilogo​

In questa lezione abbiamo dimostrato come combinare più modelli di programmazione classici e quantistici per costruire, gestire ed eseguire un workflow completo in quattro fasi. Abbiamo iniziato con i concetti fondamentali di circuiti quantistici e primitive, poi abbiamo esplorato i modelli classici come la programmazione parallela e i workflow a task. Combinando tutti i concetti, abbiamo costruito un Qiskit pattern — mappa, ottimizza, esegui e post-elabora — orchestrato dal workload manager Slurm con un semplice circuito quantistico e un'osservabile.

Nella prossima lezione utilizzeremo questo framework per eseguire algoritmi quantistici basati sul campionamento, mostrando come questo workflow possa essere applicato per risolvere problemi significativi.

Tutto il codice e gli script usati in questo capitolo sono disponibili in questo repository Github.