Saltar al contenido principal

Comparar ajustes del transpilador

Estimación de uso: menos de un minuto en un procesador Eagle r3 (NOTA: Esto es solo una estimación. Tu tiempo de ejecución podría variar).

Contexto

Para garantizar resultados más rápidos y eficientes, a partir del 1 de marzo de 2024, los circuitos y observables necesitan transformarse para usar solo instrucciones soportadas por la QPU (Unidad de Procesamiento Cuántico) antes de enviarse a las primitivas de Qiskit Runtime. A estos los llamamos circuitos y observables de arquitectura del conjunto de instrucciones (ISA - Instruction Set Architecture). Una forma común de hacerlo es usar la función generate_preset_pass_manager del transpilador. Sin embargo, podrías elegir seguir un proceso más manual.

Por ejemplo, es posible que desees orientar o enfocarte en (target) un subconjunto específico de qubits en un dispositivo específico. Este tutorial prueba el rendimiento de diferentes configuraciones (settings) del transpilador al completar todo el proceso de creación, transpilación y envío de circuitos.

Requisitos

Antes de comenzar, asegúrate de tener instalado lo siguiente:

  • El SDK de Qiskit v1.2 o superior, con soporte de visualización
  • Qiskit Runtime v0.28 o posterior (pip install qiskit-ibm-runtime)

Configuración inicial (Setup)

# Added by doQumentation — required packages for this notebook
!pip install -q qiskit qiskit-ibm-runtime
# Create circuit to test transpiler on
from qiskit import QuantumCircuit
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.circuit.library import GroverOperator, Diagonal

# Use Statevector object to calculate the ideal output
from qiskit.quantum_info import Statevector
from qiskit.visualization import plot_histogram
from qiskit.transpiler import PassManager

from qiskit.circuit.library import XGate
from qiskit.quantum_info import hellinger_fidelity

# Qiskit Runtime
from qiskit_ibm_runtime import (
QiskitRuntimeService,
Batch,
SamplerV2 as Sampler,
)
from qiskit_ibm_runtime.transpiler.passes.scheduling import (
ASAPScheduleAnalysis,
PadDynamicalDecoupling,
)

Paso 1: Mapear entradas clásicas a un problema cuántico

Crea un circuito pequeño para que el transpilador intente optimizar. Este ejemplo crea un circuito que lleva a cabo el algoritmo de Grover con un oráculo que marca el estado 111. A continuación, simula la distribución ideal (lo que esperarías medir si ejecutaras esto en una computadora cuántica perfecta un número infinito de veces) para compararla más adelante.

# To run on hardware, select the backend with the fewest number of jobs in the queue
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=127
)
backend.name
'ibm_brisbanse'
oracle = Diagonal([1] * 7 + [-1])
qc = QuantumCircuit(3)
qc.h([0, 1, 2])
qc = qc.compose(GroverOperator(oracle))

qc.draw(output="mpl", style="iqp")

Output of the previous code cell

ideal_distribution = Statevector.from_instruction(qc).probabilities_dict()

plot_histogram(ideal_distribution)

Output of the previous code cell

Paso 2: Optimizar el problema para la ejecución en hardware cuántico

A continuación, transpila los circuitos para la QPU. Compararás el rendimiento del transpilador con el optimization_level configurado en 0 (el más bajo) versus 3 (el más alto). El nivel de optimización más bajo hace el mínimo fundamental necesario para lograr que el circuito se ejecute en el dispositivo; mapea los qubits del circuito a los qubits del dispositivo y añade puertas de intercambio (swap gates) para permitir todas las operaciones de dos qubits. El nivel de optimización más alto es mucho más inteligente y usa muchos trucos para reducir el recuento total de puertas (gate count). Dado que las puertas de múltiples qubits tienen altas tasas de error y los qubits pierden coherencia (decohere) a lo largo del tiempo, los circuitos más cortos deberían dar resultados mejores.

La siguiente celda transpila qc para ambos valores de optimization_level, imprime la cantidad de puertas de dos qubits, e inserta (añade) los circuitos transpilados a una lista. Algunos de los algoritmos del transpilador son aleatorios, por lo que establece una semilla (seed) para fines de reproducibilidad.

# Need to add measurements to the circuit
qc.measure_all()

# Find the correct two-qubit gate
twoQ_gates = set(["ecr", "cz", "cx"])
for gate in backend.basis_gates:
if gate in twoQ_gates:
twoQ_gate = gate

circuits = []
for optimization_level in [0, 3]:
pm = generate_preset_pass_manager(
optimization_level, backend=backend, seed_transpiler=0
)
t_qc = pm.run(qc)
print(
f"Two-qubit gates (optimization_level={optimization_level}): ",
t_qc.count_ops()[twoQ_gate],
)
circuits.append(t_qc)
Two-qubit gates (optimization_level=0):  21
Two-qubit gates (optimization_level=3): 14

Dado que las CNOT usualmente tienen una alta tasa de error, el circuito transpilado con optimization_level=3 debería de rendir mucho mejor.

Otra forma en que puedes mejorar el rendimiento es mediante el desacoplamiento dinámico (dynamic decoupling), aplicando una secuencia de puertas a los qubits inactivos (o en ralentí, idling). Esto cancela algunas interacciones no deseadas con el ambiente o el entorno. La siguiente celda de código añade un desacoplamiento dinámico al circuito transpilado con optimization_level=3 y lo agrega a la lista.

# Get gate durations so the transpiler knows how long each operation takes
durations = backend.target.durations()

# This is the sequence we'll apply to idling qubits
dd_sequence = [XGate(), XGate()]

# Run scheduling and dynamic decoupling passes on circuit
pm = PassManager(
[
ASAPScheduleAnalysis(durations),
PadDynamicalDecoupling(durations, dd_sequence),
]
)
circ_dd = pm.run(circuits[1])

# Add this new circuit to our list
circuits.append(circ_dd)
circ_dd.draw(output="mpl", style="iqp", idle_wires=False)

Output of the previous code cell

Paso 3: Ejecutar utilizando las primitivas de Qiskit

En este punto, tienes una lista de circuitos transpilados para la QPU especificada. A continuación, crea una instancia de la primitiva sampler e inicia un trabajo por lotes (batched job) usando el gestor de contexto (with ...:), que automáticamente abre y cierra el lote.

Dentro del gestor de contexto, realiza el muestreo de los circuitos y almacena los resultados en result.

with Batch(backend=backend):
sampler = Sampler()
job = sampler.run(
[(circuit) for circuit in circuits], # sample all three circuits
shots=8000,
)
result = job.result()

Paso 4: Pos-procesar y retornar el resultado en el formato clásico deseado

Finalmente, grafica los resultados de las ejecuciones del dispositivo contra la distribución ideal. Puedes observar que los resultados con optimization_level=3 están más cercanos a la distribución ideal debido al menor recuento de puertas, y optimization_level=3 + dd es aún más cercano debido al desacoplamiento dinámico.

binary_prob = [
{
k: v / res.data.meas.num_shots
for k, v in res.data.meas.get_counts().items()
}
for res in result
]
plot_histogram(
binary_prob + [ideal_distribution],
bar_labels=False,
legend=[
"optimization_level=0",
"optimization_level=3",
"optimization_level=3 + dd",
"ideal distribution",
],
)

Output of the previous code cell

Puedes confirmar esto calculando la fidelidad de Hellinger (Hellinger fidelity) entre cada conjunto de resultados y la distribución ideal (un valor más alto es mejor, y 1 indica fidelidad perfecta).

for prob in binary_prob:
print(f"{hellinger_fidelity(prob, ideal_distribution):.3f}")
0.848
0.945
0.990