Skip to main content
IBM Quantum Platform

Optimización de la transpilación con SABRE

Estimación de tiempo de ejecución: 1 minuto en un procesador Heron r2 (NOTA: Se trata únicamente de una estimación. (El tiempo de ejecución puede variar.)


Resultados del aprendizaje

Una vez que hayas completado este tutorial, deberías haber comprendido lo siguiente:

  • Cómo configurar los parámetros de SABRE (layout_trials, swap_trials, max_iterations) para mejorar la calidad de la transpilación
  • Las relaciones de compensación entre el tiempo de ejecución de la transpilación y la calidad del circuito (profundidad y número de puertas)
  • Cómo personalizar la heurística de enrutamiento de SABRE (basic, decay, lookahead) y comparar su rendimiento en hardware

Requisitos previos

Te recomendamos que te familiarices con los siguientes temas antes de seguir este tutorial:


En segundo plano

La transpilación convierte los circuitos cuánticos en formatos compatibles con un hardware cuántico específico. Las dos etapas clave son la elección de una disposición de qubits (asignación de qubits lógicos a qubits físicos) y el enrutamiento de puertas (inserción de puertas SWAP para que las puertas de múltiples qubits respeten la conectividad del dispositivo).

SABRE ( algoritmo de búsqueda heurística bidireccional basado en SWAP ) optimiza tanto la disposición como el enrutamiento. Resulta especialmente eficaz para circuitos a gran escala (más de 100 qubits) en dispositivos con mapas de acoplamiento complejos, como los procesadores Heron de IBM®. SABRE minimiza las puertas SWAP y reduce la profundidad del circuito, lo que mejora la fidelidad de ejecución. Las recientes mejoras en el algoritmo « LightSABRE » reducen aún más los tiempos de ejecución y el número de puertas lógicas.

En este tutorial, primero configurarás el programa SabreLayout con diferentes parámetros para optimizar un pequeño circuito GHZ y observarás cómo afecta esto a la fidelidad de la ejecución. A continuación, compararás las heurísticas de enrutamiento de SABRE a gran escala en hardware real.


Requisitos

Antes de empezar este tutorial, asegúrate de tener instalado lo siguiente:

  • Qiskit SDK v2.0 o posterior, con soporte para visualización
  • Qiskit Runtime v0.22 o posterior (pip install qiskit-ibm-runtime)
  • Qiskit Aer (pip install qiskit-aer)

Configuración

from qiskit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import EstimatorOptions
from qiskit_ibm_runtime import EstimatorV2 as Estimator
from qiskit_aer.primitives import EstimatorV2 as AerEstimator
from qiskit.transpiler.passes import (
    SabreLayout,
    SabreSwap,
    BarrierBeforeFinalMeasurements,
    StarPreRouting,
)
from qiskit.transpiler.passes.layout.vf2_layout import VF2LayoutStopReason
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.passmanager.flow_controllers import ConditionalController
import matplotlib.pyplot as plt
import numpy as np
import time

seed = 42

service = QiskitRuntimeService(
    channel="ibm_cloud",
    token="<YOUR_API_TOKEN>",  # Replace with your actual API token
    instance="<YOUR_INSTANCE_NAME>",  # Replace with your instance name if needed
)
backend = service.least_busy(operational=True, simulator=False)


print(f"Using backend: {backend.name}")

Output:

Using backend: ibm_kingston

Ejemplo de simulador a pequeña escala

En esta sección se utiliza un simulador con ruido, basado en el modelo de ruido del backend real, para demostrar cómo las diferentes SabreLayout configuraciones afectan tanto a la calidad de la transpilación como a la fidelidad de la ejecución. El uso de qiskit_aer con un modelo de ruido derivado de datos reales de calibración del hardware te permite probar la transpilación sin consumir créditos de hardware.

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

Construimos un circuito GHZ con topología en estrella de 15 qubits. El primer qubit es el nodo central, y las puertas CNOT lo conectan directamente con todos los demás qubits. Esta topología plantea un problema de diseño complejo, ya que no se corresponde de forma sencilla con el mapa de acoplamiento del dispositivo.

También definimos ZZ operadores para medir las correlaciones de entrelazamiento Z0Zi\langle Z_0 Z_i \rangle entre pares de qubits.

ghz_star_topology.png
Cuando conoces la estructura del circuito

SABRE es un algoritmo de uso general y no parte de ningún supuesto sobre la estructura del circuito. Para este circuito GHZ con topología en estrella, se conoce de hecho un enrutamiento óptimo: el StarPreRouting «pass» detecta los subcircuitos en estrella y los reescribe en una cadena lineal que se adapta directamente a cualquier backend con una ruta lineal lo suficientemente larga. Este tutorial se centra en SABRE porque funciona con circuitos arbitrarios, pero si sabes que tu circuito tiene una estructura especial bien definida, aplicar una pasada especializada antes StarPreRouting del enrutamiento puede ofrecer mejores resultados que cualquier búsqueda heurística.

num_qubits_sim = 15

# Create star-topology GHZ circuit
qc_sim = QuantumCircuit(num_qubits_sim)
qc_sim.h(0)
for i in range(1, num_qubits_sim):
    qc_sim.cx(0, i)
qc_sim.measure_all()

# ZZ operators: Z on qubit 0 and qubit i, identity elsewhere
operator_strings_sim = [
    "Z" + "I" * i + "Z" + "I" * (num_qubits_sim - 2 - i)
    for i in range(num_qubits_sim - 1)
]
operators_sim = [SparsePauliOp(op) for op in operator_strings_sim]

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

El gestor de pasadas predefinido optimization_level=3 ya utiliza SabreLayout, pero con valores predeterminados conservadores. Para analizar el impacto de unos parámetros más estrictos, esa pasada se sustituye por una personalizada SabreLayout , configurada para realizar una búsqueda más exhaustiva, mientras que el resto de pasadas de la fase de maquetación no se modifican. A modo de comparación adicional, un cuarto gestor de pasadas mantiene la configuración predeterminada SabreLayout , pero añade StarPreRouting a la fase de inicialización. StarPreRouting es una pasada que tiene en cuenta la estructura, que detecta los subcircuitos en forma de estrella y los reescribe en una cadena lineal antes del enrutamiento.

El proceso es el siguiente:

  1. Revisa el gestor de pases predeterminado para ver dónde SabreLayout se encuentra dentro de la layout etapa.
  2. Sustituye esa llamada por una instancia personalizada SabreLayout utilizando PassManager.replace(index, passes=...), y compila la pm_star variante con pm.init += StarPreRouting().
  3. Ejecuta los cuatro gestores de pases y compara las métricas.

Las cuatro configuraciones son:

Configurar
Descripción
pm_1 (Valor predeterminado)Preajuste predeterminado de « level-3 » (SabreLayout con max_iterations=4, layout_trials=20, swap_trials=20)
pm_2Personalizado SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200)
pm_3Personalizado SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200)
pm_starConfiguración predeterminada con StarPreRouting añadido a la fase de inicialización

Parámetros clave de SABRE:

  • layout_trials / swap_trials: Controla el número de diseños y soluciones de enrutamiento que explora SABRE. Al aumentar el número de pruebas, SABRE explora un espacio de búsqueda más amplio, lo que aumenta las posibilidades de encontrar una solución mejor.
  • max_iterations: Determina el número de ciclos de refinamiento de enrutamiento hacia adelante y hacia atrás que SABRE realiza con cada candidato. SABRE mejora el diseño de forma iterativa aprendiendo de la información obtenida del enrutamiento, por lo que cuantas más iteraciones haya, mejores serán las mejoras.

Ambas opciones conllevan un mayor tiempo de transpilación, pero los circuitos resultantes son más cortos y utilizan menos puertas lógicas, lo que reduce directamente la decoherencia y los errores de las puertas lógicas en el hardware real.

Paso 2a: : Revisa el gestor de contraseñas predeterminado. A StagedPassManager está compuesto por etapas (init, layout, routing, translation, optimization, scheduling), cada una de las cuales es a su vez un PassManager. Al llamar .draw() a una función en un escenario, esta representa sus pasos en forma de gráfico, lo que nos permite ver dónde SabreLayout se encuentra.

# Build the default pass manager (no modifications yet)
pm_1 = generate_preset_pass_manager(
    optimization_level=3, backend=backend, seed_transpiler=seed
)

# Visualize the layout stage to see where SabreLayout sits
pm_1.layout.draw()

Output:

Output of the previous code cell

En el diagrama anterior, el SabreLayout paso que queremos personalizar se encuentra en la ConditionalController posición [2] de la etapa de maquetación. Ese controlador hace dos cosas:

  • Se activa SabreLayout de forma selectiva, por lo que solo se ejecuta cuando VF2Layout[ no] se ha podido encontrar una correspondencia perfecta (en caso contrario, se mantiene la disposición perfecta de « VF2 »).
  • Se inicia SabreLayout con una BarrierBeforeFinalMeasurements pasada que evita que las mediciones se reordenen durante el enrutamiento interno de SabreLayout's.

Si simplemente replace(index=2, passes=sl_2), ambos comportamientos se descartan. Para conservarlos, volvemos a envolver nuestro producto personalizado SabreLayout en el mismo material ConditionalController (en las mismas condiciones y con la misma barrera protectora) antes de colocarlo en su sitio.

Paso 2b: : Crea pases personalizados SabreLayout y sustituye los predeterminados.

cmap = backend.coupling_map

# Custom SabreLayout passes with more aggressive search
sl_2 = SabreLayout(
    coupling_map=cmap,
    seed=seed,
    max_iterations=4,
    layout_trials=200,
    swap_trials=200,
)
sl_3 = SabreLayout(
    coupling_map=cmap,
    seed=seed,
    max_iterations=8,
    layout_trials=200,
    swap_trials=200,
)


# Same condition the preset uses: only run SabreLayout when VF2Layout did not
# find a perfect mapping. This preserves any perfect layout VF2 produced at [1].
def _vf2_match_not_found(property_set):
    if property_set["layout"] is None:
        return True
    return (
        property_set["VF2Layout_stop_reason"] is not None
        and property_set["VF2Layout_stop_reason"]
        is not VF2LayoutStopReason.SOLUTION_FOUND
    )


def wrap_sabre(sabre_pass):
    """Re-wrap a SabreLayout in the original ConditionalController + barrier."""
    return ConditionalController(
        [
            BarrierBeforeFinalMeasurements(
                "qiskit.transpiler.internal.routing.protection.barrier"
            ),
            sabre_pass,
        ],
        condition=_vf2_match_not_found,
    )


# Build two fresh pass managers and swap in the wrapped custom SabreLayout at index 2
pm_2 = generate_preset_pass_manager(
    optimization_level=3, backend=backend, seed_transpiler=seed
)
pm_3 = generate_preset_pass_manager(
    optimization_level=3, backend=backend, seed_transpiler=seed
)
pm_2.layout.replace(index=2, passes=wrap_sabre(sl_2))
pm_3.layout.replace(index=2, passes=wrap_sabre(sl_3))

# Build pm_star: default preset with StarPreRouting added to the init stage
pm_star = generate_preset_pass_manager(
    optimization_level=3, backend=backend, seed_transpiler=seed
)
pm_star.init += StarPreRouting()

# Visualize pm_3 after replacement (pm_2 has the same structure, only max_iterations differs)
pm_3.layout.draw()

Output:

Output of the previous code cell

La posición [2] vuelve a ser un ConditionalController — de forma idéntica a la predeterminada, pero el interior SabreLayout es el que hemos definido nosotros (con layout_trials=200, swap_trials=200, y max_iterations=8 para pm_3; pm_2 es idéntico salvo por max_iterations=4). La barrera protectora y el _vf2_match_not_found sistema de control de acceso se mantienen, por lo que la única diferencia entre pm_2/pm_3 y pm_1 es la propia configuración de SABRE. pm_star mantiene la configuración predeterminada SabreLayout y solo añade StarPreRouting al final de la fase de inicialización.

Paso 2c: : Ejecuta cada gestor de pasadas y compáralos.

results_sim = {}
for name, pm in [
    ("pm_1 (4,20,20)", pm_1),
    ("pm_2 (4,200,200)", pm_2),
    ("pm_3 (8,200,200)", pm_3),
    ("pm_star (default + StarPreRouting)", pm_star),
]:
    t0 = time.time()
    tqc = pm.run(qc_sim)
    elapsed = time.time() - t0
    depth = tqc.depth(lambda x: x.operation.num_qubits == 2)
    size = tqc.size()
    ops_mapped = [op.apply_layout(tqc.layout) for op in operators_sim]
    results_sim[name] = {
        "tqc": tqc,
        "ops": ops_mapped,
        "depth": depth,
        "size": size,
        "time": elapsed,
    }
    print(f"{name}: 2Q Depth {depth}, Size {size}, Time {elapsed:.2f}s")

# Print improvement relative to default (pm_1)
baseline = results_sim["pm_1 (4,20,20)"]
print("\nImprovement vs. default (pm_1):")
for name in [
    "pm_2 (4,200,200)",
    "pm_3 (8,200,200)",
    "pm_star (default + StarPreRouting)",
]:
    r = results_sim[name]
    depth_pct = (baseline["depth"] - r["depth"]) / baseline["depth"] * 100
    size_pct = (baseline["size"] - r["size"]) / baseline["size"] * 100
    print(f"  {name}: 2Q depth {depth_pct:+.1f}%, size {size_pct:+.1f}%")

Output:

pm_1 (4,20,20): 2Q Depth 38, Size 183, Time 0.01s
pm_2 (4,200,200): 2Q Depth 36, Size 183, Time 0.15s
pm_3 (8,200,200): 2Q Depth 30, Size 158, Time 0.16s
pm_star (default + StarPreRouting): 2Q Depth 26, Size 160, Time 0.01s

Improvement vs. default (pm_1):
  pm_2 (4,200,200): 2Q depth +5.3%, size +0.0%
  pm_3 (8,200,200): 2Q depth +21.1%, size +13.7%
  pm_star (default + StarPreRouting): 2Q depth +31.6%, size +12.6%

Los tres gestores de pasadas modificados generaron circuitos con una profundidad de « 2Q » menor que la predeterminada. Las configuraciones agresivas de SABRE (pm_2 y pm_3) sacrifican un mayor tiempo de transpilación a cambio de una búsqueda más amplia, mientras que pm_star aprovecha la estructura en estrella del circuito y produce un resultado aún menos profundo sin incurrir en ningún coste adicional de transpilación. Los resultados exactos variarán de una ejecución a otra, pero la tendencia general es constante: un mayor número de pruebas e iteraciones de SABRE permite a la búsqueda heurística abarcar un espacio más amplio, y las pasadas que tienen en cuenta la estructura, como StarPreRouting , pueden evitar por completo esa búsqueda cuando la forma del circuito coincide.

Incluso a esta escala tan reducida (15 qubits), el margen de mejora es tal que los tres enfoques superan al método predeterminado. En el caso de circuitos más grandes (más de 100 qubits), el espacio de búsqueda aumenta de forma espectacular y las ventajas tanto del mayor número de intentos como de las pasadas que tienen en cuenta la estructura se hacen mucho más evidentes, tal y como se verá en la sección dedicada a los circuitos a gran escala.

pm_names = list(results_sim.keys())
depths = [results_sim[n]["depth"] for n in pm_names]
sizes = [results_sim[n]["size"] for n in pm_names]
times = [results_sim[n]["time"] for n in pm_names]
colors = ["#404080", "#2a9d8f", "#a8d05e", "#e29bdd"]
x = np.arange(len(pm_names))

fig, axs = plt.subplots(1, 3, figsize=(14, 5))

# 2Q Depth
bars = axs[0].bar(x, depths, color=colors)
axs[0].set_ylabel("2Q Depth", fontsize=11)
axs[0].set_title("Two-Qubit Gate Depth", fontsize=13)
axs[0].set_ylim(0, max(depths) * 1.2)
for bar, val in zip(bars, depths):
    axs[0].text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + max(depths) * 0.02,
        str(val),
        ha="center",
        va="bottom",
        fontsize=11,
        fontweight="bold",
    )
for i in range(1, len(depths)):
    pct = (depths[0] - depths[i]) / depths[0] * 100
    if pct != 0:
        axs[0].text(
            bars[i].get_x() + bars[i].get_width() / 2,
            bars[i].get_height() / 2,
            f"{pct:+.0f}%",
            ha="center",
            va="center",
            fontsize=10,
            color="white",
            fontweight="bold",
        )

# Size
bars = axs[1].bar(x, sizes, color=colors)
axs[1].set_ylabel("Gate Count", fontsize=11)
axs[1].set_title("Circuit Size", fontsize=13)
axs[1].set_ylim(0, max(sizes) * 1.2)
for bar, val in zip(bars, sizes):
    axs[1].text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + max(sizes) * 0.02,
        str(val),
        ha="center",
        va="bottom",
        fontsize=11,
        fontweight="bold",
    )
for i in range(1, len(sizes)):
    pct = (sizes[0] - sizes[i]) / sizes[0] * 100
    if abs(pct) > 0.1:
        axs[1].text(
            bars[i].get_x() + bars[i].get_width() / 2,
            bars[i].get_height() / 2,
            f"{pct:+.0f}%",
            ha="center",
            va="center",
            fontsize=10,
            color="white",
            fontweight="bold",
        )

# Time
bars = axs[2].bar(x, times, color=colors)
axs[2].set_ylabel("Time (s)", fontsize=11)
axs[2].set_title("Transpilation Time", fontsize=13)
axs[2].set_ylim(0, max(times) * 1.3)
for bar, val in zip(bars, times):
    axs[2].text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + max(times) * 0.03,
        f"{val:.2f}s",
        ha="center",
        va="bottom",
        fontsize=11,
        fontweight="bold",
    )

for ax in axs:
    ax.set_xticks(x)
    ax.set_xticklabels(pm_names, fontsize=8, rotation=15)
    ax.grid(axis="y", linestyle="--", alpha=0.5)

plt.suptitle(
    "Transpilation quality vs. configuration",
    fontsize=14,
    fontweight="bold",
    y=1.02,
)
plt.tight_layout()
plt.show()

Output:

Output of the previous code cell

Paso 3: Ejecutar utilizando Qiskit primitives

Ejecutamos cada circuito transpilado 10 veces utilizando Aer EstimatorV2 con un modelo de ruido derivado del backend real. Dado que los resultados de las simulaciones, que presentan ruido, varían de una ejecución a otra, calcular la media de varias ejecuciones ofrece estimaciones de la fidelidad más fiables y nos permite cuantificar la incertidumbre estadística mediante barras de error.

# Create a noisy estimator from the real backend's noise model
noisy_estimator = AerEstimator.from_backend(backend)

num_runs = 10
# sim_all_runs[name] = list of arrays, one per run
sim_all_runs = {name: [] for name in results_sim}

for run in range(num_runs):
    for name, r in results_sim.items():
        job = noisy_estimator.run([(r["tqc"], r["ops"])])
        evs = list(job.result()[0].data.evs)
        sim_all_runs[name].append(evs)
    print(f"Run {run + 1}/{num_runs} done")

# Compute mean and std across runs for each config
sim_stats = {}
for name in results_sim:
    all_evs = np.array(sim_all_runs[name])  # shape (num_runs, num_operators)
    sim_stats[name] = {
        "mean": np.mean(all_evs, axis=0),
        "std": np.std(all_evs, axis=0),
        "overall_mean": np.mean(all_evs),
        "overall_std": np.std(
            np.mean(all_evs, axis=1)
        ),  # std of per-run averages
    }
    print(
        f"{name}: mean fidelity = {sim_stats[name]['overall_mean']:.4f} +/- {sim_stats[name]['overall_std']:.4f}"
    )

Output:

Run 1/10 done
Run 2/10 done
Run 3/10 done
Run 4/10 done
Run 5/10 done
Run 6/10 done
Run 7/10 done
Run 8/10 done
Run 9/10 done
Run 10/10 done
pm_1 (4,20,20): mean fidelity = 0.9510 +/- 0.0094
pm_2 (4,200,200): mean fidelity = 0.9513 +/- 0.0043
pm_3 (8,200,200): mean fidelity = 0.9540 +/- 0.0065
pm_star (default + StarPreRouting): mean fidelity = 0.9547 +/- 0.0072

Al tratarse de un circuito pequeño, los valores de fidelidad son bastante similares en las cuatro configuraciones. Los circuitos son lo suficientemente cortos como para que el ruido del hardware no afecte de forma significativa ni siquiera a la versión menos optimizada. La fidelidad media sigue, en líneas generales, la profundidad de 2Q : pm_3 y pm_star, los dos circuitos menos profundos, alcanzan las fidelidades más altas y están prácticamente empatados dentro de sus barras de error. pm_2 es un contraejemplo útil: aunque su profundidad de « 2Q » es menor que pm_1la de, su fidelidad media también resulta ser ligeramente inferior, lo que nos recuerda que la relación entre profundidad y fidelidad es de carácter estadístico y no determinista. También son importantes los qubits concretos que selecciona una configuración y la calibración de dichos qubits en tiempo de ejecución.

Paso 4: Procesamiento posterior y devolución del resultado en el formato clásico deseado

A continuación, representa gráficamente las correlaciones de entrelazamiento Z0Zi\langle Z_0 Z_i \rangle en función de la distancia entre los qubits, junto con la correlación media como única métrica de fidelidad. En un caso ideal (sin ruido), todas las correlaciones serían 1. Dado que el ruido es realista, cada puerta adicional introduce un error y cada paso de tiempo adicional permite la decoherencia, por lo que un circuito transpilado con menor profundidad y menos puertas (especialmente puertas de dos qubits) debería conservar mejor el entrelazamiento.

data_sim = list(range(1, len(operators_sim) + 1))
markers = ["o", "s", "^", "*"]
colors_line = ["#404080", "#2a9d8f", "#a8d05e", "#e29bdd"]

fig, (ax1, ax2) = plt.subplots(
    1, 2, figsize=(14, 5), gridspec_kw={"width_ratios": [2.5, 1]}
)

# Left: correlations vs distance with error bars (mean +/- 1 std)
for (name, stats), marker, color in zip(
    sim_stats.items(), markers, colors_line
):
    ax1.errorbar(
        data_sim,
        stats["mean"],
        yerr=stats["std"],
        marker=marker,
        label=name,
        color=color,
        linewidth=2,
        capsize=3,
        capthick=1,
        elinewidth=1,
    )

ax1.set_xlabel("Distance between qubits $i$", fontsize=11)
ax1.set_ylabel(r"$\langle Z_0 Z_i \rangle$", fontsize=11)
ax1.set_title(
    "Entanglement correlations vs. qubit distance (avg. of 10 runs)",
    fontsize=12,
)
ax1.legend(fontsize=9)
ax1.grid(alpha=0.3)

# Right: mean correlation bar chart with error bars
names = list(sim_stats.keys())
means = [sim_stats[n]["overall_mean"] for n in names]
stds = [sim_stats[n]["overall_std"] for n in names]
x_bar = np.arange(len(names))
bars = ax2.bar(
    x_bar, means, yerr=stds, color=colors_line, capsize=5, ecolor="gray"
)
ax2.set_ylabel(r"Mean $\langle Z_0 Z_i \rangle$", fontsize=11)
ax2.set_title("Average fidelity", fontsize=13, pad=12)
y_range = max(means) - min(means) if max(means) != min(means) else 0.01
# Top of ylim accounts for the bar height + std error bar + headroom for the value label
y_top = max(m + s for m, s in zip(means, stds)) + y_range * 1.5
ax2.set_ylim(min(means) - y_range * 0.8, y_top)
for bar, val, std in zip(bars, means, stds):
    ax2.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + std + y_range * 0.15,
        f"{val:.4f}",
        ha="center",
        va="bottom",
        fontsize=10,
        fontweight="bold",
    )
# Annotate % change vs pm_1
baseline_mean = means[0]
for i in range(1, len(means)):
    pct = (means[i] - baseline_mean) / baseline_mean * 100
    if abs(pct) > 0.01:
        mid_y = (means[i] + ax2.get_ylim()[0]) / 2
        ax2.text(
            bars[i].get_x() + bars[i].get_width() / 2,
            mid_y,
            f"{pct:+.1f}%",
            ha="center",
            va="center",
            fontsize=10,
            color="white",
            fontweight="bold",
        )
ax2.set_xticks(x_bar)
ax2.set_xticklabels(names, fontsize=8, rotation=15)
ax2.grid(axis="y", linestyle="--", alpha=0.5)

fig.tight_layout()
plt.show()

Output:

Output of the previous code cell

Los resultados muestran una clara relación entre la calidad de la transpilación y la fidelidad de la ejecución, con algunas salvedades que conviene tener en cuenta:

  • pm_1 (por defecto) : Línea de base. Con solo 20 pruebas y cuatro iteraciones, SABRE tiene un margen limitado para la optimización, lo que da como resultado el circuito más profundo de entre los generados exclusivamente por SABRE.
  • pm_2 (más pruebas) : Al explorar diez veces más candidatos se encuentra una disposición ligeramente menos profunda, pero la fidelidad media se mantiene prácticamente estable (e incluso puede descender por debajo del valor de referencia debido al ruido), ya que la ganancia en profundidad es pequeña a esta escala.
  • pm_3 (más pruebas + más iteraciones) : Al duplicar max_iterations el número a 8, SABRE dispone de más ciclos de refinamiento, lo que da como resultado el circuito más superficial creado exclusivamente con SABRE y la fidelidad media más alta de la comparación.
  • pm_star (predeterminado + StarPreRouting ) : Añade StarPreRouting a la fase de inicialización de un preajuste que, por lo demás, sería el predeterminado. La reescritura que tiene en cuenta la estructura reduce el símbolo «*» a una cadena lineal que el resto del transpilador asigna a la ruta lineal del dispositivo, lo que da como resultado el circuito de menor profundidad en general (ligeramente mejor que pm_3) y una fidelidad equivalente pm_3 a dentro de los márgenes de error. Lo hace en el mismo tiempo de transpilación que la opción predeterminada, ya que la reescritura es prácticamente gratuita en comparación con la búsqueda estocástica de SABRE.

Ten en cuenta que aumentar max_iterations no siempre tiene un impacto positivo. En este caso resultó de gran ayuda, pero en otros circuitos o backends es posible que las iteraciones adicionales no aporten más mejoras, o que incluso perjudiquen ligeramente el rendimiento debido a una optimización excesiva de un mínimo local. En general, deberías aumentar layout_trials y swap_trials tanto como te lo permita el tiempo del que dispongas, ya que un mayor número de intentos siempre aumenta las posibilidades de encontrar una disposición mejor. Merece la pena probar el aumento max_iterations , pero debe validarse para tu caso de uso concreto. Los pases especializados como StarPreRouting tienen un espíritu similar, pero dependen más del circuito: solo son útiles cuando el circuito contiene realmente la estructura a la que se dirigen. La ganancia es elevada cuando procede y, en caso contrario, es nula, pero probarlas no cuesta prácticamente nada.


Ejemplo de hardware a gran escala

Además de ajustar el número de intentos, SABRE permite personalizar la heurística de enrutamiento. SABRE ofrece tres heurísticas:

  • basic: Un método codicioso sencillo que selecciona el intercambio que minimiza la distancia inmediata a la siguiente puerta.
  • decay (por defecto): Ponderiza dinámicamente los qubits en función de la actividad reciente, lo que desalienta los intercambios repetidos en los mismos qubits.
  • lookahead: Evalúa los costes de enrutamiento futuros analizando las próximas puertas, lo que podría permitir encontrar mejores secuencias de intercambio.

Para utilizar una heurística personalizada, crea un SabreSwap «pass» y conéctalo a SabreLayout mediante el routing_pass parámetro.

SabreLayoutSe añade un cuarto gestor de pasadas a la comparación: pm_star_hw, que mantiene la configuración predeterminada,SabreSwap pero añade StarPreRouting a la fase de inicialización. A esta escala (100 qubits), la búsqueda SABRE resulta más compleja, y la reescritura de una cadena en estrella a una cadena lineal supone una clara ventaja, ya que un procesador Heron cuenta con rutas lineales lo suficientemente largas como para albergar el circuito resultante.

Aquí comparamos las tres heurísticas de SABRE, además de StarPreRouting a gran escala, en un circuito GHZ de 100 qubits. Realizamos varias pruebas de diseño con diferentes semillas para las configuraciones de SABRE, seleccionamos el mejor circuito transpilado de cada una de ellas y los enviamos todos al hardware real junto con el StarPreRouting resultado.

Pasos 1 a 4 comprimidos en un único bloque de código

Aquí se presenta el flujo de trabajo completo a mayor escala. Cuando se utiliza SabreSwap como para routing_pass SabreLayout, solo se realiza un intento de disposición por llamada, por lo que la siguiente celda de código recorre las semillas para explorar el espacio de disposición.

Utilizamos el mismo wrap_sabre ayudante definido en el paso 2 a pequeña escala (más arriba) y añadimos un ayudante análogo wrap_routing , ya que la routing etapa en el índice [1] también es un ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) —; si la sustituyéramos sin más, se eliminaría igualmente la barrera protectora y la _swap_condition activación.

# -------------------------Step 1-------------------------

num_qubits = 100

# Create star-topology GHZ circuit
qc = QuantumCircuit(num_qubits)
qc.h(0)
for i in range(1, num_qubits):
    qc.cx(0, i)
qc.measure_all()

# ZZ operators
operator_strings = [
    "Z" + "I" * i + "Z" + "I" * (num_qubits - 2 - i)
    for i in range(num_qubits - 1)
]
operators = [SparsePauliOp(op) for op in operator_strings]
# -------------------------Step 2-------------------------

num_seeds = 10
seed_list = [seed + i for i in range(num_seeds)]
swap_trials = 200


# The default routing[1] is a ConditionalController([barrier, routing_pass],
# condition=_swap_condition); we re-wrap so the new routing pass keeps the
# protective barrier and is skipped when routing isn't needed (matches the preset).
def _swap_condition(property_set):
    return not property_set["routing_not_needed"]


def wrap_routing(routing_pass):
    return ConditionalController(
        [
            BarrierBeforeFinalMeasurements(
                "qiskit.transpiler.internal.routing.protection.barrier"
            ),
            routing_pass,
        ],
        condition=_swap_condition,
    )


heuristic_results = {}

# Three SABRE heuristics, swept over seeds
for heuristic in ["basic", "decay", "lookahead"]:
    trials = []
    for s in seed_list:
        sr = SabreSwap(
            coupling_map=cmap, heuristic=heuristic, trials=swap_trials, seed=s
        )
        sl = SabreLayout(coupling_map=cmap, routing_pass=sr, seed=s)
        pm = generate_preset_pass_manager(
            optimization_level=3, backend=backend, seed_transpiler=s
        )
        # Re-wrap each custom pass in its original ConditionalController + barrier
        # (wrap_sabre is defined in the small-scale Step 2 cell above).
        pm.layout.replace(index=2, passes=wrap_sabre(sl))
        pm.routing.replace(index=1, passes=wrap_routing(sr))

        t0 = time.time()
        tqc = pm.run(qc)
        elapsed = time.time() - t0
        depth = tqc.depth(lambda x: x.operation.num_qubits == 2)
        size = tqc.size()
        trials.append(
            {
                "tqc": tqc,
                "depth": depth,
                "size": size,
                "time": elapsed,
                "seed": s,
            }
        )

    heuristic_results[heuristic] = trials

# Default preset + StarPreRouting in init, also swept over seeds for a fair comparison
star_trials = []
for s in seed_list:
    pm_star_hw = generate_preset_pass_manager(
        optimization_level=3, backend=backend, seed_transpiler=s
    )
    pm_star_hw.init += StarPreRouting()

    t0 = time.time()
    tqc = pm_star_hw.run(qc)
    elapsed = time.time() - t0
    depth = tqc.depth(lambda x: x.operation.num_qubits == 2)
    size = tqc.size()
    star_trials.append(
        {
            "tqc": tqc,
            "depth": depth,
            "size": size,
            "time": elapsed,
            "seed": s,
        }
    )
heuristic_results["StarPreRouting"] = star_trials

# Print summary for each entry
for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
    trials = heuristic_results[label]
    depths = [t["depth"] for t in trials]
    sizes = [t["size"] for t in trials]
    best = min(trials, key=lambda t: t["depth"])
    print(f"{label}:")
    print(
        f"  2Q depth: min: {min(depths)}, mean: {np.mean(depths):.1f}, std: {np.std(depths):.1f}"
    )
    print(
        f"  size    : min: {min(sizes)}, mean: {np.mean(sizes):.1f}, std: {np.std(sizes):.1f}"
    )
    print(
        f"  best seed: {best['seed']} (2Q depth={best['depth']}, size={best['size']})"
    )

Output:

basic:
  2Q depth: min: 524, mean: 570.5, std: 39.9
  size    : min: 3819, mean: 4227.1, std: 360.6
  best seed: 51 (2Q depth=524, size=3852)
decay:
  2Q depth: min: 387, mean: 436.4, std: 41.7
  size    : min: 2687, mean: 3183.1, std: 459.3
  best seed: 45 (2Q depth=387, size=2786)
lookahead:
  2Q depth: min: 364, mean: 424.6, std: 36.5
  size    : min: 2335, mean: 3014.6, std: 388.1
  best seed: 51 (2Q depth=364, size=2485)
StarPreRouting:
  2Q depth: min: 196, mean: 196.0, std: 0.0
  size    : min: 1151, mean: 1151.0, std: 0.0
  best seed: 42 (2Q depth=196, size=1151)
hw_colors = {
    "basic": "#ff7f0e",
    "decay": "#d62728",
    "lookahead": "#1f77b4",
    "StarPreRouting": "#2a9d8f",
}

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))

for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
    trials = heuristic_results[label]
    depths = [t["depth"] for t in trials]
    sizes = [t["size"] for t in trials]
    seeds = [t["seed"] for t in trials]
    color = hw_colors[label]

    ax1.scatter(
        seeds,
        depths,
        label=label,
        color=color,
        alpha=0.8,
        edgecolor="k",
        s=60,
    )
    ax1.axhline(np.mean(depths), color=color, linestyle="--", alpha=0.5)

    ax2.scatter(
        seeds,
        sizes,
        label=label,
        color=color,
        alpha=0.8,
        edgecolor="k",
        s=60,
    )
    ax2.axhline(np.mean(sizes), color=color, linestyle="--", alpha=0.5)

ax1.set_xlabel("Seed", fontsize=11)
ax1.set_ylabel("2Q Depth", fontsize=11)
ax1.set_title("Two-Qubit Gate Depth per Seed", fontsize=13)
ax1.legend(fontsize=10)
ax1.grid(alpha=0.3)

ax2.set_xlabel("Seed", fontsize=11)
ax2.set_ylabel("Gate Count", fontsize=11)
ax2.set_title("Circuit Size per Seed", fontsize=13)
ax2.legend(fontsize=10)
ax2.grid(alpha=0.3)

plt.suptitle(
    "Transpilation variability across seeds: SABRE heuristics vs. StarPreRouting",
    fontsize=14,
    fontweight="bold",
    y=1.02,
)
plt.tight_layout()
plt.show()

# Summary comparison
for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
    best = min(heuristic_results[label], key=lambda t: t["depth"])
    print(
        f"{label}: best 2Q depth={best['depth']}, size={best['size']} (seed={best['seed']})"
    )

Output:

Output of the previous code cell
basic: best 2Q depth=524, size=3852 (seed=51)
decay: best 2Q depth=387, size=2786 (seed=45)
lookahead: best 2Q depth=364, size=2485 (seed=51)
StarPreRouting: best 2Q depth=196, size=1151 (seed=42)
# -------------------------Step 3: Execute on hardware-------------------------

best_circuits = {}
for label in ["basic", "decay", "lookahead", "StarPreRouting"]:
    best_circuits[label] = min(
        heuristic_results[label], key=lambda t: t["depth"]
    )
    b = best_circuits[label]
    print(f"Best {label}: 2Q depth={b['depth']}, size={b['size']}")

options = EstimatorOptions()
options.resilience_level = 2
options.dynamical_decoupling.enable = True
options.dynamical_decoupling.sequence_type = "XY4"
estimator = Estimator(backend, options=options)

hw_jobs = {}
hw_ops = {}
for label, best in best_circuits.items():
    hw_ops[label] = [op.apply_layout(best["tqc"].layout) for op in operators]
    hw_jobs[label] = estimator.run([(best["tqc"], hw_ops[label])])
    print(f"{label} job: {hw_jobs[label].job_id()}")
estimator.options.environment.job_tags = ["TUT_TOWS"]

hw_results = {}
for label, job in hw_jobs.items():
    hw_results[label] = job.result()[0]
    print(f"{label} job done")

Output:

Best basic: 2Q depth=524, size=3852
Best decay: 2Q depth=387, size=2786
Best lookahead: 2Q depth=364, size=2485
Best StarPreRouting: 2Q depth=196, size=1151
basic job: d81q5tnoha1c73bknprg
decay job: d81q5tugbeec73aktopg
lookahead job: d81q5to0bvlc73d1epe0
StarPreRouting job: d81q5u7tjchs73bn82hg
basic job done
decay job done
lookahead job done
StarPreRouting job done
# -------------------------Step 4: Post-process-------------------------

data = list(range(1, len(operators) + 1))
hw_markers = {
    "basic": "D",
    "decay": "o",
    "lookahead": "s",
    "StarPreRouting": "*",
}
hw_labels = ["basic", "decay", "lookahead", "StarPreRouting"]

fig, (ax1, ax2) = plt.subplots(
    1, 2, figsize=(14, 5), gridspec_kw={"width_ratios": [2.5, 1]}
)

# Left: correlations vs distance
for label in hw_labels:
    evs = list(hw_results[label].data.evs)
    b = best_circuits[label]
    ax1.plot(
        data,
        evs,
        marker=hw_markers[label],
        color=hw_colors[label],
        linewidth=2,
        label=f"{label} (2Q depth={b['depth']}, size={b['size']})",
        markersize=5 if label == "StarPreRouting" else 4,
    )

ax1.set_xlabel("Distance between qubits $i$", fontsize=11)
ax1.set_ylabel(r"$\langle Z_0 Z_i \rangle$", fontsize=11)
ax1.set_title(
    "Entanglement correlations vs. qubit distance (hardware)", fontsize=12
)
ax1.legend(fontsize=9)
ax1.grid(alpha=0.3)

# Right: mean fidelity bar chart
hw_means = [np.mean(list(hw_results[label].data.evs)) for label in hw_labels]
hw_bar_colors = [hw_colors[label] for label in hw_labels]
x_bar = np.arange(len(hw_labels))
bars = ax2.bar(x_bar, hw_means, color=hw_bar_colors)
ax2.set_ylabel(r"Mean $\langle Z_0 Z_i \rangle$", fontsize=11)
ax2.set_title("Average fidelity", fontsize=13)
y_range = (
    max(hw_means) - min(hw_means) if max(hw_means) != min(hw_means) else 0.01
)
ax2.set_ylim(min(hw_means) - y_range * 0.2, max(hw_means) + y_range * 0.15)
for bar, val in zip(bars, hw_means):
    ax2.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + y_range * 0.05,
        f"{val:.4f}",
        ha="center",
        va="bottom",
        fontsize=11,
        fontweight="bold",
    )
ax2.set_xticks(x_bar)
ax2.set_xticklabels(hw_labels, fontsize=9, rotation=15)
ax2.grid(axis="y", linestyle="--", alpha=0.5)

fig.tight_layout()
plt.show()

print("\nMean fidelity:")
for label, m in zip(hw_labels, hw_means):
    print(f"  {label}: {m:.4f}")

Output:

Output of the previous code cell

Mean fidelity:
  basic: 0.0344
  decay: 0.1298
  lookahead: 0.1857
  StarPreRouting: 0.3295

Análisis

Los diagramas de dispersión muestran una variabilidad significativa entre las semillas para las tres heurísticas de SABRE, lo que pone de relieve la importancia de realizar múltiples pruebas de diseño en lugar de basarse en una única transpilación. La StarPreRouting línea se mantiene prácticamente plana en todas las semillas, ya que la reescritura de una estructura en forma de estrella a una cadena lineal es determinista dada la estructura; el enrutamiento posterior de SABRE tiene entonces muy poca libertad en una cadena lineal, por lo que la semilla casi no influye en la profundidad o el tamaño finales.

Según los resultados de la transpilación, tanto la decay heurística como lookahead la superan sistemáticamente a basic por un amplio margen. La basic heurística, aunque rápida, utiliza una estrategia codiciosa sencilla que a menudo da lugar a circuitos considerablemente más profundos. En el caso de este circuito GHZ con topología en estrella, lookahead tiende a generar la menor profundidad de « 2Q » y el menor número de puertas entre las heurísticas SABRE, ya que su función de coste prospectiva se adapta bien a circuitos con patrones de conectividad de largo alcance. StarPreRouting, Sin embargo, supera a los tres por un margen considerable: al reescribir la estrella en una cadena lineal antes del enrutamiento, elude por completo el problema de búsqueda y genera un circuito que el resto del transpilador puede mapear en una ruta lineal con un número mínimo de SWAP adicionales.

Esa ventaja se refleja directamente en la fidelidad del hardware. Una menor profundidad de « 2Q » y un menor número de puertas no siempre se traducen directamente en una mayor fidelidad (también influyen los qubits físicos específicos que utiliza un diseño y su calibración en tiempo de ejecución), pero cuando la diferencia de profundidad es tan grande como la que existe entre SABRE y StarPreRouting este caso, el enfoque que tiene en cuenta la estructura gana de forma contundente, ya que el circuito acumula mucha menos decoherencia y muchos menos eventos de error de dos qubits. El gráfico de barras de fidelidad muestra StarPreRouting que se sitúa muy por delante incluso de la mejor heurística de SABRE, mientras que basic se encuentra muy por debajo del resto, ya que sus circuitos, mucho más complejos, acumulan la mayor parte del error.

Puntos clave:

  • Entre las heurísticas de SABRE, decay y lookahead son considerablemente mejores que basic para circuitos no triviales. Elige una de las dos opciones para las cargas de trabajo de producción.
  • La mejor heurística SABRE depende de tu circuito y de tu hardware. Probar varias heurísticas con varias semillas es la estrategia más fiable.
  • Si quieres explorar aún más configuraciones, aumenta swap_trials (y layout_trials cuando no estés asignando una ruta personalizada) en lugar de distribuir el trabajo entre nodos remotos. Los pases de SABRE ya paralelizan las pruebas entre subprocesos locales, y el trabajo por prueba es tan reducido que la sobrecarga de la distribución suele superar cualquier aumento de velocidad.
  • Cuando el circuito tiene una estructura especial conocida, aplicar una pasada que tenga en cuenta dicha estructura —como StarPreRouting antes de SABRE— puede suponer una mejora de un orden de magnitud que ningún ajuste de SABRE podrá igualar. Esto no sustituye a SABRE: StarPreRouting solo resulta útil cuando el circuito contiene realmente subcircuitos en estrella y el backend tiene una trayectoria lineal lo suficientemente larga. Merece la pena consultar la biblioteca de pases para ver si hay coincidencias siempre que conozcas la forma de tu circuito.

Próximos pasos

Si este trabajo te ha parecido interesante, quizá te interese el siguiente material:

Recomendaciones

Encuesta tutorial

Responda a esta breve encuesta para darnos su opinión sobre este tutorial. Su opinión nos ayudará a mejorar nuestra oferta de contenidos y la experiencia de los usuarios.

Enlace a la encuesta

¿Le ha resultado útil esta página?
Informe de un error, de una errata o solicite contenido en GitHub.