Pular para o conteúdo principal

Modelos de programação

Modelos de programação são especificações fundamentais que definem como o software é estruturado e executado. Eles fornecem um framework para que desenvolvedores expressem algoritmos e organizem código, muitas vezes abstraindo os detalhes de baixo nível do hardware subjacente ou do ambiente de execução. Diferentes modelos são adequados para diferentes tipos de problemas e arquiteturas de hardware, oferecendo níveis variados de abstração e controle.

Nesta lição, vamos revisar modelos de programação quânticos e clássicos e ver como podemos combiná-los para operar algoritmos em ambientes heterogêneos. Iskandar Sitdikov nos dá uma visão geral no vídeo a seguir.

Modelo de programação para QPUs

Vamos começar com o modelo de programação para computadores quânticos. O modelo de programação fundamental, familiar para quase todos os desenvolvedores quânticos, é o circuito quântico. Não entraremos em detalhes do modelo de circuito quântico aqui, pois já temos uma excelente aula do John Watrous que explica isso em detalhes. Mencionaremos apenas que o circuito é construído a partir de um conjunto de linhas (chamadas de fios) que representam qubits, portas que representam operações sobre estados quânticos, e um conjunto de medições.

Um diagrama de circuito quântico mostrando qubits como linhas horizontais e portas quânticas como caixas ou conexões entre os qubits.

Outro conceito importante de modelo de programação para computação quântica é o que chamamos de primitivas computacionais. Essas primitivas representam algumas das tarefas mais comuns que os usuários buscam realizar com um computador quântico. Existem várias primitivas disponíveis no momento, incluindo o Executor. Neste curso, vamos nos concentrar principalmente nas primitivas Sampler e Estimator. O Sampler oferece a capacidade de amostrar um estado preparado pelo seu circuito quântico. Ele informa quais estados da base computacional compõem o estado quântico preparado no seu circuito quântico. O Estimator permite estimar o valor esperado de um observável para um sistema no estado preparado pelo seu circuito quântico. Um contexto comum é estimar a energia de um sistema em um estado específico.

Um histograma modelo de resultados do Sampler. Alguns estados têm alta probabilidade de serem medidos, outros têm probabilidade muito baixa.

A última coisa que vamos abordar nesta seção é a transpilação. Transpilação é o processo de reescrever um circuito de entrada para corresponder às restrições físicas e à Arquitetura de Conjunto de Instruções (ISA) de um dispositivo quântico específico. De forma semelhante aos compiladores clássicos, isso significa traduzir operações unitárias abstratas para o conjunto de portas nativas que o dispositivo-alvo consegue executar. Também otimiza as instruções do circuito para execução eficiente em computadores quânticos ruidosos, com a rotina alterando gradualmente a estrutura do circuito ao aplicar vários estágios de otimização.

Um diagrama de transpilação mostrando como um circuito abstrato é mapeado para um circuito de arquitetura de conjunto de instruções. Ou seja, o circuito é reescrito usando as portas nativas e a conectividade do hardware-alvo.

Verifique seu entendimento

Quantos qubits há no circuito abaixo? Um diagrama de circuito com quatro linhas horizontais e muitas portas.

Resposta:

Quatro.

Verifique seu entendimento

Suponha que você esteja modelando os elétrons em uma molécula. Você quer aproximar (a) a energia do estado fundamental da molécula e (b) quais estados da base computacional são mais dominantes no estado fundamental da molécula. Em cada caso, você usaria a primitiva Estimator ou Sampler?

Resposta:

(a) Estimator (b) Sampler

Modelos de programação clássicos

Existem muitos modelos de programação para computadores clássicos, mas nesta seção vamos nos concentrar em dois dos mais populares: programação paralela e workflows de tarefas. Usando esses dois modelos junto com modelos de programação quântica, é possível expressar praticamente qualquer workflow híbrido quântico-clássico de qualquer complexidade.

Programação paralela

Programação paralela é um modelo que divide um programa em subproblemas que podem ser executados simultaneamente. Existem dois paradigmas principais de programação paralela:

  • Paralelismo de memória compartilhada (Open Multiprocessing, ou OpenMP): usado para explorar múltiplos núcleos dentro de um único nó de computação. As threads de execução compartilham um único espaço de memória.

  • Paralelismo de memória distribuída (Message Passing Interface, ou MPI): usado para escalar entre múltiplos nós de computação separados. Cada processo tem seu próprio espaço de memória isolado.

Aqui, vamos nos concentrar no modelo de memória distribuída porque ele é essencial para a computação de alto desempenho em múltiplos nós e para coordenar jobs híbridos quântico-clássicos em larga escala.

Existem alguns conceitos que precisamos entender para trabalhar com modelos de programação paralela de memória distribuída:

  • Processo — Uma instância independente do programa com seu próprio espaço de memória.
  • Rank — Um identificador inteiro único atribuído a cada processo, usado especificamente para identificar o remetente e o receptor durante a comunicação (não necessariamente um "rank" no sentido de priorização).
  • Sincronização — Um mecanismo de coordenação entre diferentes ranks e processos.
  • Programa único, múltiplos dados (SPMD) — Um modelo computacional abstrato em que uma única instância de código-fonte é executada simultaneamente em múltiplos processos, cada um operando em um subconjunto diferente do total de dados.
  • Passagem de mensagens — O paradigma de comunicação usado em arquiteturas de memória distribuída que permite que processos independentes troquem dados e resultados intermediários. Ele se baseia em operações explícitas de 'envio' e 'recebimento' para coordenar a execução entre diferentes nós de computação.

Existe um padrão chamado MPI que implementa esse paradigma de passagem de mensagens para arquiteturas paralelas. O MPI é a materialização funcional de todos os conceitos listados acima, fornecendo as chamadas de biblioteca específicas necessárias para gerenciar processos, atribuir ranks, facilitar a sincronização e habilitar a passagem de mensagens sob o modelo SPMD. Reunindo todos esses conceitos, podemos dizer que a execução de um programa paralelo acontece da seguinte forma:

  • Um único programa compilado (o mesmo arquivo binário) é copiado e executado por um lançador de jobs para criar múltiplos processos paralelos em múltiplos nós.
  • O fluxo de controle principal do programa é ditado pelo rank do processo. Este é o princípio SPMD em ação: o programa usa lógica condicional (por exemplo, if (rank == 0)) para garantir que apenas certas seções paralelizadas do código sejam executadas pelos processos trabalhadores, enquanto um processo mestre (frequentemente o Rank 0) lida com a inicialização e a agregação final.
  • A comunicação entre processos ocorre por meio de passagem de mensagens (usando MPI), que é chamada sempre que um processo precisa trocar dados ou resultados intermediários com outro rank.

Visualmente, ficará parecido com isto:

Um diagrama de uma tarefa sendo dividida entre nós.

Vamos tentar aplicar alguns dos conceitos que acabamos de aprender ao código.

Primeiro, vamos tentar executar um programa paralelo simples de "hello world" usando OpenMPI, que é uma implementação do protocolo MPI, um padrão para passagem de mensagens em programação paralela. Aqui, usaremos o pacote Python mpi4py, que é um binding Python para o padrão MPI (Message Passing Interface).

$ 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")

~
~

Vamos usar dois nós para executar este programa, o que especificaremos no nosso script de submissão.

$ 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

Em seguida, execute o script shell.

$ sbatch mpi-hello-world.sh

Podemos verificar os logs de resultado do 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}

Aqui usamos dois nós e o processo em cada nó agora é identificado por um rank — Rank 0 e Rank 1 — que são usados para decidir o fluxo de controle do programa.

Workflows de tarefas

Agora vamos falar sobre o modelo de programação de workflow de tarefas. Um workflow de tarefas abstrai a computação em um grafo acíclico dirigido (DAG). Nesse grafo, cada nó representa uma tarefa ou job específico, e as arestas (as setas que conectam os nós) representam as dependências (de dados e de ordenação) entre eles. Um escalonador é o componente que mapeia tarefas para recursos e orquestra a execução.

Um exemplo concreto de um modelo de workflow de tarefas aplicado à computação quântica é o framework Qiskit patterns. Um Qiskit pattern é um framework geral projetado para decompor problemas específicos de domínio em uma sequência de estágios, especialmente para tarefas quânticas. Isso permite a composabilidade fluida de novas capacidades desenvolvidas pelos pesquisadores do IBM Quantum® (e outros) e viabiliza um futuro em que tarefas de computação quântica são executadas por uma poderosa infraestrutura de computação heterogênea (CPU/GPU/QPU). Os quatro passos de um Qiskit pattern são mapeamento, otimização, execução e pós-processamento, onde todas as tarefas são executadas uma após a outra em um pipeline. Mas com workflows de tarefas não estamos limitados a uma ordem de execução linear e podemos executar tarefas em paralelo. Cada tarefa de um workflow pode ser um job paralelo completo por si só. Assim, você pode combinar esses modelos para descrever algoritmos arbitrariamente complexos, e um gerenciador de carga de trabalho como o Slurm vai lidar com isso.

Um diagrama de tarefas de computação organizadas em um workflow no qual alguns processos são executados em paralelo e outros em sequência.

A imagem acima ilustra o Qiskit pattern em ação. O workflow tem uma estrutura de grafo com quatro estágios. Essa estrutura ramificada é orquestrada e executada pelo escalonador. O problema é mapeado para uma forma executável quanticamente (circuito quântico) no estágio inicial. No próximo estágio, esse circuito quântico é otimizado para o hardware quântico específico. A imagem mostra isso como um processo paralelo, demonstrando como múltiplas estratégias de otimização podem ser aplicadas ao mesmo tempo. O circuito quântico otimizado é então executado no hardware quântico real. Este é o terceiro estágio da imagem, onde o escalonador trabalha com uma unidade de processamento quântico roxa. Por fim, os resultados são pós-processados por recursos clássicos.

Por que ambos?

Então, por que precisamos tanto de programação paralela quanto de workflows de tarefas? Com toda a conversa sobre paralelismo quântico, vale esclarecer que nem tudo é paralelo na computação quântica.

A lição anterior sobre o workflow SQD mencionou alguns processos que não podem ser paralelizados. Por exemplo, precisamos dos resultados de muitas medições quânticas para projetar nossa matriz em um subespaço de dimensão tratável. Por sua vez, precisamos da matriz diagonalizada e dos vetores de estado associados para verificar a autoconsistência das medições quânticas (usando, por exemplo, conservação de carga). Depois de tudo isso, precisamos decidir se a energia do estado fundamental convergiu suficientemente para nossos propósitos. Esses passos são necessariamente sequenciais e exigem testes de convergência e de autoconsistência antes de prosseguir.

Um esquema do workflow específico para a diagonalização quântica baseada em amostras. Os passos incluem um circuito quântico variacional, uso de medições para projetar o Hamiltoniano em um subespaço, e então uso de um otimizador clássico para atualizar os parâmetros variacionais no circuito e repetir.

Este workflow será revisitado com mais detalhes e implementado na próxima seção. A única coisa que você precisa guardar desta seção é que workflows de tarefas são necessários.

Prática de programação

A beleza dos modelos de programação é que você pode combiná-los todos. Conhecendo os modelos de programação quânticos e clássicos, você pode descrever uma computação heterogênea de complexidade arbitrária e executá-la no hardware. Vamos praticar com um pequeno exemplo de workflow combinado, que implementa o Qiskit pattern (mapear, otimizar, executar e pós-processar) dentro do Slurm, que aprendemos no último capítulo. Cada uma das quatro tarefas será um job Slurm separado, cada um com seus próprios recursos. A tarefa de otimização usará MPI para otimizar circuitos em paralelo (apenas como exemplo, como na imagem acima). A tarefa de execução usará recursos quânticos e modelos de programação quântica (circuito e sampler). A última tarefa — pós-processamento — usará novamente MPI em paralelo com recursos clássicos.

Mapeamento

O programa mapping.py é projetado para construir um circuito PauliTwoDesign, frequentemente usado na literatura de aprendizado de máquina quântico e em benchmarks quânticos, com um observável simples que mede o (n1)th(n-1)^\text{th} qubit na direção ZZ de um sistema de nn qubits com parâmetros iniciais aleatórios. Cada um desses elementos (o circuito quântico convertido em um arquivo qasm, o observável e os parâmetros) será salvo em um arquivo separado no diretório de dados e será usado como entrada no estágio de otimização.

O script shell deste estágio (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

que define o nome do job, o formato de saída e o número de nós/tarefas/CPUs.

Otimização

O programa optimization.py começa carregando os arquivos do estágio de mapeamento. Aqui você usará QRMI para trazer recursos quânticos para este programa.

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

Em seguida, realiza uma otimização leve configurando optimization_level=1 para transpilar o circuito quântico e aplicar o layout do circuito ao observável, salvando-os na pasta de dados.

O script shell deste estágio (optimization.sh) é

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

srun python3 /tmp/optimization.py

Aqui --ntasks=4 solicita quatro tarefas clássicas do Slurm para um processo paralelo.

Execução

Este é o estágio quântico principal, onde o circuito quântico otimizado da etapa anterior é executado na QPU pelo Estimator. Para isso, primeiro vamos carregar três arquivos — o circuito quântico transpilado, o observável e os parâmetros iniciais — e então passá-los ao Estimator. Ele produz o valor estimado do observável e o imprime.

O script execution.sh utiliza um plugin do Slurm para usar um recurso quântico.

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

Pós-processamento

O passo de pós-processamento frequentemente envolve diagonalização clássica e verificações de autoconsistência. Também pode ser iterativo. É mais útil considerar o passo de pós-processamento na próxima lição, na qual o contexto físico e o propósito dos passos iterativos ficam claros.

Combinando tudo

Podemos encadear todas essas tarefas em um workflow usando o argumento de dependência do 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 podemos verificar nossa fila de execução do 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)

Este foi um exemplo didático para demonstrar a mistura de modelos de programação. No próximo capítulo, vamos analisar algoritmos do mundo real e demonstrar modelos de programação e gerenciamento de recursos em workflows úteis.

Resumo

Nesta lição, demonstramos como combinar múltiplos modelos de programação clássicos e quânticos para construir, gerenciar e executar um workflow completo de quatro estágios. Começamos com os conceitos fundamentais de circuitos quânticos e primitivas, depois exploramos modelos clássicos como programação paralela e workflows de tarefas. Combinando todos os conceitos, construímos um Qiskit pattern — mapear, otimizar, executar e pós-processar — orquestrado pelo gerenciador de carga de trabalho Slurm com um circuito quântico simples e um observável.

Na próxima lição, usaremos este framework para executar algoritmos quânticos baseados em amostras, mostrando como esse workflow pode ser aplicado para resolver problemas significativos.

Todo o código e os scripts usados neste capítulo estão disponíveis para você neste repositório do Github.