Aller au contenu principal

Modèles de programmation

Les modèles de programmation sont des spécifications fondamentales qui définissent comment les logiciels sont structurés et exécutés. Ils fournissent un cadre permettant aux développeurs d'exprimer des algorithmes et d'organiser du code, en faisant souvent abstraction des détails de bas niveau du matériel sous-jacent ou de l'environnement d'exécution. Différents modèles sont adaptés à différents types de problèmes et d'architectures matérielles, offrant différents niveaux d'abstraction et de contrôle.

Dans cette leçon, nous passerons en revue les modèles de programmation quantiques et classiques et verrons comment les combiner pour faire fonctionner des algorithmes dans des environnements hétérogènes. Iskandar Sitdikov nous en donne un aperçu dans la vidéo suivante.

Modèle de programmation pour les QPU

Nous commencerons par le modèle de programmation pour les ordinateurs quantiques. Le modèle de programmation fondamental, familier à presque tous les développeurs quantiques, est le circuit quantique. Nous n'entrerons pas dans les détails du modèle de circuit quantique ici, car nous disposons déjà d'un excellent cours de John Watrous qui l'explique en détail. Nous mentionnerons seulement que le circuit est construit à partir d'un ensemble de lignes (appelées fils) représentant des qubits, de portes représentant des opérations sur les états quantiques, et d'un ensemble de mesures.

Un diagramme de circuit quantique montrant les qubits sous forme de lignes horizontales et les portes quantiques sous forme de boîtes ou de connexions entre qubits.

Un autre concept de modèle de programmation important pour l'informatique quantique est ce que nous appelons les primitives de calcul. Ces primitives représentent certaines des tâches les plus courantes que les utilisateurs cherchent à accomplir avec un ordinateur quantique. Il existe plusieurs primitives disponibles actuellement, notamment Executor. Dans ce cours, nous nous concentrerons principalement sur les primitives Sampler et Estimator. Sampler te donne la possibilité d'échantillonner un état préparé par ton circuit quantique. Il t'indique quels états de la base computationnelle composent l'état quantique préparé sur ton circuit quantique. Estimator te permet d'estimer la valeur d'espérance d'un observable pour un système dans l'état préparé par ton circuit quantique. Un contexte courant est l'estimation de l'énergie d'un système dans un état spécifique.

Un histogramme modèle des résultats du Sampler. Certains états ont une forte probabilité d'être mesurés, d'autres une très faible.

La dernière chose dont nous allons parler dans cette section est la transpilation. La transpilation est le processus de réécriture d'un circuit d'entrée donné pour correspondre aux contraintes physiques et à l'architecture du jeu d'instructions (ISA) d'un dispositif quantique spécifique. Similaire aux compilateurs classiques, cela implique de traduire des opérations unitaires abstraites vers le jeu de portes natif que le dispositif cible peut exécuter. Cela optimise également les instructions du circuit pour une exécution efficace sur des ordinateurs quantiques bruités, la routine modifiant progressivement la structure du circuit en appliquant plusieurs étapes d'optimisation.

Un diagramme de transpilation montrant comment un circuit abstrait est mappé vers un circuit d'architecture de jeu d'instructions. C'est-à-dire que le circuit est réécrit en utilisant les portes natives et la connectivité du matériel cible.

Vérifie ta compréhension

Combien de qubits y a-t-il dans le circuit ci-dessous ? Un diagramme de circuit avec quatre lignes horizontales et beaucoup de portes.

Réponse :

Quatre.

Vérifie ta compréhension

Suppose que tu modélises les électrons dans une molécule. Tu veux approcher (a) l'énergie de l'état fondamental de la molécule, et (b) quels états de la base computationnelle sont les plus dominants dans l'état fondamental de la molécule. Dans chaque cas, utiliserais-tu la primitive Estimator ou Sampler ?

Réponse :

(a) Estimator (b) Sampler

Modèles de programmation classiques

Il existe de nombreux modèles de programmation pour les ordinateurs classiques, mais pour cette section nous nous concentrerons sur deux des plus populaires : la programmation parallèle et les workflows de tâches. En utilisant ces deux modèles conjointement aux modèles de programmation quantiques, on peut exprimer presque tout workflow hybride quantique-classique de n'importe quelle complexité.

Programmation parallèle

La programmation parallèle est un modèle qui divise un programme en sous-problèmes pouvant être exécutés simultanément. Il existe deux principaux paradigmes de programmation parallèle :

  • Parallélisme à mémoire partagée (Open Multiprocessing, ou OpenMP) : Utilisé pour exploiter plusieurs cœurs au sein d'un seul nœud de calcul. Les fils d'exécution partagent un seul espace mémoire.

  • Parallélisme à mémoire distribuée (Message Passing Interface, ou MPI) : Utilisé pour la mise à l'échelle sur plusieurs nœuds de calcul séparés. Chaque processus dispose de son propre espace mémoire isolé.

Ici, nous nous concentrerons sur le modèle à mémoire distribuée car il est essentiel pour le calcul haute performance multi-nœuds et la coordination de grands travaux hétérogènes quantiques-classiques.

Il y a quelques concepts à comprendre pour opérer dans des modèles de programmation parallèle à mémoire distribuée :

  • Processus - Une instance indépendante du programme avec son propre espace mémoire.
  • Rang - Un identifiant entier unique assigné à chaque processus, utilisé spécifiquement pour identifier l'émetteur et le destinataire lors de la communication (pas nécessairement un « rang » au sens de priorité).
  • Synchronisation - Un mécanisme de coordination entre différents rangs et processus.
  • Programme unique, données multiples (SPMD) - Un modèle de calcul abstrait où une seule instance de code source s'exécute simultanément sur plusieurs processus, chacun opérant sur un sous-ensemble différent des données totales.
  • Passage de messages - Le paradigme de communication utilisé dans les architectures à mémoire distribuée qui permet à des processus indépendants d'échanger des données et des résultats intermédiaires. Il repose sur des opérations explicites d'« envoi » et de « réception » pour coordonner l'exécution entre différents nœuds de calcul.

Il existe une norme appelée MPI qui implémente ce paradigme de passage de messages pour les architectures parallèles. MPI constitue l'incarnation fonctionnelle de tous les concepts listés ci-dessus, fournissant les appels de bibliothèque spécifiques nécessaires pour gérer les processus, assigner des rangs, faciliter la synchronisation et activer le passage de messages sous le modèle SPMD. En rassemblant tous ces concepts, nous pouvons dire que l'exécution d'un programme parallèle se déroule de la façon suivante :

  • Un seul programme compilé (le même fichier binaire) est copié et exécuté par un lanceur de tâches pour créer plusieurs processus parallèles sur plusieurs nœuds.
  • Le flux de contrôle principal du programme est dicté par le rang du processus. C'est le principe SPMD en action : le programme utilise de la logique conditionnelle (par exemple, if (rank == 0)) pour s'assurer que seules certaines sections parallélisées du code sont exécutées par les processus travailleurs, tandis qu'un processus maître (souvent le Rang 0) gère l'initialisation et l'agrégation finale.
  • La communication entre les processus se fait par passage de messages (en utilisant MPI), qui est appelée chaque fois qu'un processus a besoin d'échanger des données ou des résultats intermédiaires avec un autre rang.

Visuellement, cela ressemblera à quelque chose comme ça :

Un diagramme d'une tâche divisée entre des nœuds.

Essayons d'appliquer certains des concepts que nous venons d'apprendre au code.

D'abord, nous allons essayer d'exécuter un simple programme parallèle « hello world » en utilisant OpenMPI, qui est une implémentation du protocole MPI, une norme pour le passage de messages en programmation parallèle. Ici, nous utiliserons le paquet Python mpi4py, qui est un binding Python pour la norme 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")

~
~

Nous utiliserons deux nœuds pour exécuter ce programme, ce que nous spécifierons dans notre script de soumission.

$ 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

Puis exécute le script shell.

$ sbatch mpi-hello-world.sh

Nous pouvons vérifier les journaux de résultats du travail.

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

Ici, nous avons utilisé deux nœuds et le processus sur chaque nœud est maintenant identifié par un rang - Rang 0 et Rang 1 - qui sont utilisés pour décider du flux de contrôle du programme.

Workflows de tâches

Parlons maintenant du modèle de programmation par workflow de tâches. Un workflow de tâches abstrait le calcul en un graphe acyclique dirigé (DAG). Dans ce graphe, chaque nœud représente une tâche ou un travail particulier, et les arêtes (les flèches reliant les nœuds) représentent les dépendances (données et ordre) entre eux. Un ordonnanceur est le composant qui mappe les tâches sur les ressources et orchestre l'exécution.

Un exemple concret d'un modèle de workflow de tâches appliqué à l'informatique quantique est le cadre des patterns Qiskit. Un pattern Qiskit est un cadre général conçu pour décomposer des problèmes spécifiques à un domaine en une séquence d'étapes, notamment pour les tâches quantiques. Cela permet la composabilité transparente de nouvelles capacités développées par les chercheurs IBM Quantum® (et d'autres) et rend possible un avenir dans lequel les tâches de calcul quantique sont effectuées par une infrastructure de calcul hétérogène puissante (CPU/GPU/QPU). Les quatre étapes d'un pattern Qiskit sont le mappage, l'optimisation, l'exécution et le post-traitement, où toutes les tâches sont exécutées l'une après l'autre dans un pipeline. Mais avec les workflows de tâches, nous ne sommes pas limités à un ordre d'exécution linéaire et pouvons exécuter des tâches en parallèle. Chaque tâche d'un workflow peut être un travail parallèle entier en lui-même. Ainsi, on peut combiner ces modèles pour décrire des algorithmes arbitrairement complexes, et un gestionnaire de charge de travail comme Slurm les gérera.

Un diagramme de tâches de calcul organisées en un workflow dans lequel certains processus sont exécutés en parallèle et d'autres en séquence.

L'image ci-dessus illustre le pattern Qiskit en action. Le workflow a une structure de graphe avec quatre étapes. Cette structure en branches est orchestrée et exécutée par l'ordonnanceur. Le problème est mappé sous forme exécutable par un quantum (circuit quantique) à l'étape initiale. À l'étape suivante, ce circuit quantique est optimisé pour le matériel quantique spécifique. L'image montre ceci comme un processus parallèle, démontrant comment plusieurs stratégies d'optimisation pourraient être appliquées en même temps. Le circuit quantique optimisé est ensuite exécuté sur le matériel quantique réel. C'est la troisième étape de l'image où l'ordonnanceur travaille avec une unité de traitement quantique (QPU) violette. Enfin, les résultats sont post-traités par des ressources classiques.

Pourquoi les deux ?

Alors pourquoi avons-nous besoin à la fois de la programmation parallèle et des workflows de tâches ? Pour tous les discours sur le parallélisme quantique, il convient de préciser que tout n'est pas parallèle en informatique quantique.

La leçon précédente sur le workflow SQD mentionnait certains processus qui ne peuvent pas être parallélisés. Par exemple, nous avons besoin des résultats de nombreuses mesures quantiques pour projeter notre matrice dans un sous-espace de dimension traitable. En retour, nous avons besoin de la matrice diagonalisée et des vecteurs d'état associés pour vérifier la cohérence des mesures quantiques (en utilisant, par exemple, la conservation de la charge). Après tout cela, nous devons décider si l'énergie de l'état fondamental a suffisamment convergé pour nos besoins. Ces étapes sont nécessairement séquentielles et nécessitent des tests de convergence et de cohérence avant de continuer.

Un schéma du workflow spécifique à la diagonalisation quantique basée sur des échantillons. Les étapes comprennent un circuit quantique variationnel, l'utilisation de mesures pour projeter le Hamiltonien dans un sous-espace, puis l'utilisation d'un optimiseur classique pour mettre à jour les paramètres variationnels dans le circuit et répéter.

Ce workflow sera revisité plus en détail et implémenté dans la section suivante. La seule chose que tu dois retenir de cette section est que les workflows de tâches sont nécessaires.

Pratique de programmation

La beauté des modèles de programmation est qu'on peut tous les combiner. En connaissant les modèles de programmation quantiques et classiques, tu peux décrire un calcul hétérogène de complexité arbitraire et l'exécuter sur du matériel. Pratiquons cela avec un petit exemple de workflow combiné, qui implémente le pattern Qiskit (map, optimize, execute, and post-process) dans Slurm que nous avons appris dans le dernier chapitre. Chacune des quatre tâches sera un travail Slurm séparé, chacun avec ses propres ressources. La tâche d'optimisation utilisera MPI pour optimiser les circuits en parallèle (uniquement à titre d'exemple, comme dans l'image ci-dessus). La tâche d'exécution utilisera des ressources quantiques et des modèles de programmation quantiques (circuit et sampler). La dernière tâche - le post-traitement - utilisera à nouveau MPI en parallèle avec des ressources classiques.

Mappage

Le programme mapping.py est conçu pour construire un circuit PauliTwoDesign, fréquemment utilisé dans la littérature sur l'apprentissage automatique quantique et les benchmarks quantiques, avec un observable simple qui mesure le qubit (n1)ieˋme(n-1)^\text{ième} dans la direction ZZ d'un système à nn qubits avec des paramètres initiaux aléatoires. Chacun d'entre eux (le circuit quantique converti en fichier qasm, l'observable et les paramètres) sera sauvegardé dans un fichier séparé dans le répertoire de données et servira d'entrée à l'étape d'optimisation.

Le script shell de cette étape (mapping.sh) est

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

qui définit son nom de travail, son format de sortie et le nombre de nœuds/tâches/CPU.

Optimisation

Le programme optimization.py commence par récupérer les fichiers de l'étape de mappage. Ici tu utiliseras QRMI pour apporter des ressources quantiques dans ce programme.

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

Il effectue ensuite une optimisation légère en définissant optimization_level=1 pour transpiler le circuit quantique et appliquer le layout du circuit à l'observable, puis les sauvegarde dans le dossier de données.

Le script shell de cette étape (optimization.sh) est

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

srun python3 /tmp/optimization.py

Ici --ntasks=4 demande quatre tâches classiques à Slurm pour un processus parallèle.

Exécution

C'est l'étape quantique principale où le circuit quantique optimisé de l'étape précédente est exécuté sur le QPU par Estimator. Pour ce faire, nous allons d'abord récupérer trois fichiers - le circuit quantique transpilé, l'observable et les paramètres initiaux - puis les passer à Estimator. Cela donne la valeur estimée de l'observable et l'affiche.

Le script execution.sh exploite un plugin Slurm pour utiliser une ressource quantique.

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

L'étape de post-traitement implique souvent une diagonalisation classique et des vérifications de cohérence. Elle peut aussi être itérative. Il est plus utile de considérer l'étape de post-traitement dans la leçon suivante, dans laquelle le contexte physique et l'objectif des étapes itératives sont clairs.

Tout combiner

Nous pouvons enchaîner toutes ces tâches dans un workflow en utilisant l'argument de dépendance pour la commande 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)

Et nous pouvons vérifier notre file d'attente d'exécution 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)

C'était un exemple jouet pour démontrer le mélange des modèles de programmation. Dans le prochain chapitre, nous examinerons des algorithmes du monde réel et démontrerons les modèles de programmation et la gestion des ressources sur des workflows utiles.

Résumé

Dans cette leçon, nous avons démontré comment combiner plusieurs modèles de programmation classiques et quantiques pour construire, gérer et exécuter un workflow complet en quatre étapes. Nous avons commencé par les concepts fondamentaux des circuits quantiques et des primitives, puis exploré les modèles classiques tels que la programmation parallèle et les workflows de tâches. En combinant tous les concepts, nous avons construit un pattern Qiskit — mapper, optimiser, exécuter et post-traiter — orchestré par le gestionnaire de charge de travail Slurm avec un circuit quantique simple et un observable.

Dans la prochaine leçon, nous utiliserons ce cadre pour exécuter des algorithmes quantiques basés sur des échantillons, montrant comment ce workflow peut être appliqué pour résoudre des problèmes significatifs.

Tout le code et les scripts utilisés dans ce chapitre sont disponibles dans ce dépôt Github.