Skip to main content
IBM Quantum Platform

Ottimizzazione della transpilazione con SABRE

Stima del tempo di esecuzione: 1 minuto su un processore Heron r2 (NOTA: si tratta solo di una stima. (La durata potrebbe variare.)


Risultati di apprendimento

Dopo aver seguito questo tutorial, dovresti aver compreso:

  • Come configurare i parametri SABRE (layout_trials, swap_trials, max_iterations) per migliorare la qualità della transpilazione
  • I compromessi tra il tempo di esecuzione della transpilazione e la qualità del circuito (profondità e numero di porte)
  • Come personalizzare l'euristica di instradamento SABRE (basic, decay, lookahead) e confrontarne le prestazioni su hardware

Prerequisiti

Ti consigliamo di acquisire familiarità con i seguenti argomenti prima di seguire questo tutorial:


Sfondo

La transpilazione converte i circuiti quantistici in forme compatibili con specifici dispositivi quantistici. Due fasi fondamentali sono la scelta della configurazione dei qubit (mappatura dei qubit logici sui qubit fisici) e l'instradamento dei gate (inserimento di gate SWAP affinché i gate multi-qubit rispettino la connettività del dispositivo).

SABRE ( algoritmo di ricerca euristica bidirezionale basato su SWAP ) ottimizza sia il layout che l'instradamento. È particolarmente efficace per circuiti su larga scala (oltre 100 qubit) su dispositivi con mappe di accoppiamento complesse, come i processori Heron dell' IBM®. SABRE riduce al minimo i gate SWAP e diminuisce la profondità del circuito, migliorando la fedeltà di esecuzione. I recenti miglioramenti apportati all'algoritmo " LightSABRE " riducono ulteriormente i tempi di esecuzione e il numero di porte logiche.

In questo tutorial, configurerai innanzitutto il circuito SabreLayout con diversi parametri per ottimizzare un piccolo circuito GHZ e osserverai l'impatto sulla fedeltà di esecuzione. Successivamente, confronterai le euristiche di instradamento di SABRE su larga scala su hardware reale.


Requisiti

Prima di iniziare questa esercitazione, assicuratevi di aver installato quanto segue:

  • Qiskit SDK v2.0 o versioni successive, con supporto alla visualizzazione
  • Qiskit Runtime v0.22 o versioni successive (pip install qiskit-ibm-runtime)
  • Qiskit Aer (pip install qiskit-aer)

Configura

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

Esempio di simulatore su piccola scala

In questa sezione viene utilizzato un simulatore con rumore, basato sul modello di rumore del backend reale, per dimostrare in che modo le diverse SabreLayout configurazioni influenzano sia la qualità della transpilazione sia la fedeltà di esecuzione. L'utilizzo qiskit_aer di con un modello di rumore derivato dai dati effettivi di calibrazione dell'hardware consente di testare la transpilazione senza consumare crediti hardware.

Fase 1: mappare gli input classici su un problema quantistico

Costruiamo un circuito GHZ a topologia a stella con 15 qubit. Il primo qubit funge da nodo centrale, con porte CNOT che lo collegano direttamente a tutti gli altri qubit. Questa topologia pone un problema complesso di layout, poiché non si corrisponde in modo banale alla mappa di accoppiamento del dispositivo.

Definiamo ZZ inoltre degli operatori per misurare le correlazioni di entanglement Z0Zi\langle Z_0 Z_i \rangle tra coppie di qubit.

ghz_star_topology.png
Quando si conosce la struttura del circuito

SABRE è un algoritmo generico e non formula ipotesi sulla struttura del circuito. Per questo circuito GHZ a topologia a stella, è infatti nota una soluzione ottimale di instradamento: il StarPreRouting pass individua i sottocircuiti a stella e li riscrive in una catena lineare che si mappa direttamente su qualsiasi backend dotato di un percorso lineare sufficientemente lungo. Questo tutorial si concentra su SABRE perché funziona con circuiti arbitrari, ma se sai che il tuo circuito presenta una struttura particolare ben definita, l'applicazione di StarPreRouting un passaggio specializzato prima del routing può dare risultati migliori rispetto a qualsiasi ricerca euristica.

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]

Fase 2: Ottimizzazione del problema per l'esecuzione su hardware quantistico

SabreLayoutIl gestore dei passaggi predefinito optimization_level=3 utilizza già questa impostazione, ma con valori predefiniti prudenti. Per valutare l'impatto di impostazioni più rigorose, quel passaggio viene sostituito con uno personalizzato SabreLayout e configurato per una ricerca più aggressiva, mentre tutti gli altri passaggi della fase di layout rimangono invariati. A titolo di confronto, un quarto gestore di passaggi mantiene l'impostazione predefinita SabreLayout , ma aggiunge StarPreRouting alla fase di inizializzazione. StarPreRouting è un passaggio che tiene conto della struttura, in grado di individuare i sottocircuiti a stella e di riscriverli in una catena lineare prima dell'instradamento.

Il flusso di lavoro è il seguente:

  1. Esamina il gestore dei passaggi predefinito per vedere dove SabreLayout si trova all’interno dello layout stage.
  2. Sostituisci quel pass con un'istanza personalizzata SabreLayout utilizzando PassManager.replace(index, passes=...), e crea la pm_star variante con pm.init += StarPreRouting().
  3. Esegui tutti e quattro i gestori di passaggi e confronta le metriche.

Le quattro configurazioni sono:

Configurazione
Descrizione
pm_1 (predefinito)Preimpostazione predefinita di level-3 (SabreLayout con max_iterations=4, layout_trials=20, swap_trials=20)
pm_2Personalizzato SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200)
pm_3Personalizzato SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200)
pm_starPreimpostazione predefinita con StarPreRouting aggiunto alla fase di inizializzazione

Parametri chiave di SABRE:

  • layout_trials / swap_trials: Consente di controllare il numero di layout e soluzioni di instradamento che SABRE prende in considerazione. Aumentando il numero di prove, SABRE esplora uno spazio di ricerca più ampio, aumentando così le possibilità di trovare una soluzione migliore.
  • max_iterations: Determina il numero di cicli di affinamento del routing avanti-indietro che SABRE esegue su ciascun candidato. SABRE ottimizza il layout in modo iterativo, imparando dai risultati del routing; pertanto, maggiore è il numero di iterazioni, migliori saranno i risultati.

Entrambe le soluzioni comportano tempi di transpilazione più lunghi, ma i circuiti risultanti sono più brevi e utilizzano un numero minore di porte logiche, il che riduce direttamente la decoerenza e gli errori delle porte logiche sull'hardware reale.

Passo 2a: Verificare il gestore delle password predefinito. A StagedPassManager è composto da stadi (init, layout, routing, translation, optimization, scheduling), ciascuno dei quali è a sua volta un PassManager. Quando si chiama .draw() una funzione su uno stage, i suoi passaggi vengono rappresentati sotto forma di grafico, così possiamo vedere dove SabreLayout si trova.

# 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

Nel diagramma sopra riportato, il SabreLayout passaggio che vogliamo personalizzare si trova nella ConditionalController posizione [2] della fase di layout. Quel controller svolge due funzioni:

  • Funziona in SabreLayout modo tale da attivarsi solo quando VF2Layout[ non] è stato possibile trovare una mappatura perfetta (altrimenti viene mantenuta la disposizione perfetta VF2 ).
  • È preceduto SabreLayout da un BarrierBeforeFinalMeasurements passaggio che impedisce che le misurazioni vengano riordinate durante l'instradamento interno di SabreLayout's.

Se ci limitiamo a replace(index=2, passes=sl_2), entrambi i comportamenti vengono eliminati. Per conservarli, riavvolgiamo il nostro prodotto personalizzato SabreLayout nello stesso ConditionalController imballaggio (nelle stesse condizioni e con la stessa barriera protettiva) prima di sostituirlo.

Passaggio 2b: : creare pass personalizzati SabreLayout e sostituire quelli predefiniti.

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 posizione [2] ora è di nuovo un ConditionalController — identico nella forma a quello predefinito, ma il interno SabreLayout è quello personalizzato (con layout_trials=200, swap_trials=200, e max_iterations=8 per pm_3; pm_2 è identico tranne che per max_iterations=4). La barriera protettiva e il sistema _vf2_match_not_found di gating vengono mantenuti, quindi l'unica differenza tra pm_2/pm_3 e pm_1 è la configurazione SABRE stessa. pm_star mantiene l'impostazione predefinita SabreLayout e aggiunge StarPreRouting solo alla fine della fase di inizializzazione.

Passaggio 2c: : eseguire ciascun pass manager e confrontare i risultati.

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%

Tutti e tre i gestori di passaggi modificati hanno generato circuiti con una profondità di " 2Q " inferiore rispetto a quella predefinita. Le configurazioni SABRE aggressive (pm_2 e pm_3) sacrificano un tempo di transpilazione più lungo a favore di una ricerca più ampia, mentre pm_star sfrutta la struttura a stella del circuito e produce un risultato ancora più superficiale senza comportare alcun costo aggiuntivo di transpilazione. I miglioramenti esatti variano da esecuzione a esecuzione, ma la tendenza generale è costante: un numero maggiore di prove e iterazioni SABRE consente alla ricerca euristica di esplorare uno spazio più ampio, mentre i passaggi che tengono conto della struttura, come StarPreRouting [...], possono aggirare completamente tale ricerca quando la forma del circuito corrisponde.

Anche su questa scala ridotta (15 qubit), il margine di miglioramento è tale che tutti e tre gli approcci superano il risultato predefinito. Con circuiti più grandi (oltre 100 qubit), lo spazio di ricerca cresce in modo esponenziale e i vantaggi derivanti sia dall’aumento del numero di tentativi sia dai passaggi che tengono conto della struttura diventano molto più evidenti, come verrà illustrato nella sezione dedicata ai circuiti su larga scala.

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

Passaggio 3: eseguire utilizzando Qiskit primitives

Eseguiamo ogni circuito transpilato 10 volte utilizzando Aer EstimatorV2 con un modello di rumore derivato dal backend reale. Poiché i risultati delle simulazioni, caratterizzati da una certa variabilità, variano da un'esecuzione all'altra, il calcolo della media su più esecuzioni fornisce stime di fedeltà più affidabili e ci consente di quantificare l'incertezza statistica mediante barre di errore.

# 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

Trattandosi di un circuito di piccole dimensioni, i valori di fedeltà risultano relativamente simili in tutte e quattro le configurazioni. I circuiti sono sufficientemente corti da impedire che il rumore dell'hardware penalizzi in modo significativo anche la versione meno ottimizzata. La fedeltà media riflette in linea di massima la profondità dell' 2Q : pm_3 e pm_star, i due circuiti meno profondi, raggiungono i livelli di fedeltà più elevati e risultano sostanzialmente alla pari entro i rispettivi intervalli di errore. pm_2 è un utile controesempio: sebbene la sua profondità di " 2Q " sia inferiore a pm_1quella di 's, anche la sua fedeltà media risulta leggermente inferiore, il che ci ricorda che il rapporto tra profondità e fedeltà è di natura statistica piuttosto che deterministica. Anche la scelta specifica dei qubit da parte di un layout e la loro calibrazione durante l'esecuzione sono fattori importanti.

Fase 4: Post-elaborazione e restituzione del risultato nel formato classico desiderato

Successivamente, tracciare le correlazioni di entanglement Z0Zi\langle Z_0 Z_i \rangle in funzione della distanza tra i qubit, insieme alla correlazione media come unico indicatore di fedeltà. In un caso ideale (privo di rumore), tutte le correlazioni sarebbero pari a 1. Considerando il rumore realistico, ogni porta aggiuntiva introduce un errore e ogni passo temporale aggiuntivo favorisce la decoerenza; pertanto, un circuito transpilato con profondità inferiore e un numero minore di porte (in particolare porte a due qubit) dovrebbe preservare meglio l’entanglement.

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

I risultati evidenziano una chiara correlazione tra la qualità della transpilazione e la fedeltà di esecuzione, con alcune precisazioni utili:

  • pm_1 (impostazione predefinita) : Baseline. Con sole 20 prove e quattro iterazioni, SABRE ha un margine di ottimizzazione limitato, il che porta alla realizzazione del circuito più profondo tra quelli realizzati esclusivamente con SABRE.
  • pm_2 (ulteriori prove) : Esplorando un numero di candidati dieci volte superiore si ottiene una struttura leggermente meno profonda, ma la fedeltà media rimane sostanzialmente invariata (e può persino scendere al di sotto del valore di riferimento a causa del rumore), poiché il guadagno in termini di profondità è esiguo a questa scala.
  • pm_3 (più prove + più iterazioni) : Raddoppiando max_iterations il valore a 8, SABRE ottiene un maggior numero di cicli di affinamento, generando il circuito realizzato esclusivamente con SABRE meno profondo e la fedeltà media più elevata nel confronto.
  • pm_star (impostazione predefinita + StarPreRouting ) : aggiunge StarPreRouting alla fase di inizializzazione di un preset che, per il resto, è quello predefinito. La riscrittura che tiene conto della struttura riduce il simbolo asterisco a una catena lineare che il resto del transpiler mappa sul percorso lineare del dispositivo, producendo il circuito complessivamente meno profondo (leggermente migliore di pm_3) e raggiungendo pm_3 la stessa fedeltà entro i margini di errore. Lo fa con lo stesso tempo di transpilazione dell'impostazione predefinita, poiché la riscrittura è sostanzialmente gratuita rispetto alla ricerca stocastica di SABRE.

Si noti che un aumento max_iterations di non ha sempre un impatto positivo. In questo caso è stato di grande aiuto, ma per altri circuiti o backend le iterazioni aggiuntive potrebbero non portare a ulteriori miglioramenti, o potrebbero addirittura compromettere leggermente le prestazioni a causa di un’eccessiva ottimizzazione di un minimo locale. In generale, è consigliabile aumentare layout_trials e swap_trials il più possibile, nei limiti del tempo a disposizione, poiché un numero maggiore di tentativi aumenta sempre la probabilità di trovare una disposizione migliore. L'aumento max_iterations merita di essere testato, ma dovrebbe essere verificato in base al vostro caso d'uso specifico. I passaggi specializzati come StarPreRouting sono simili nello spirito, ma dipendono maggiormente dal circuito: sono utili solo quando il circuito contiene effettivamente la struttura a cui si riferiscono. Il guadagno è elevato quando applicabile e pari a zero in caso contrario, ma provarle non costa praticamente nulla.


Esempio di hardware su larga scala

Oltre a regolare il numero di prove, SABRE consente di personalizzare l 'euristica di instradamento. SABRE offre tre euristiche:

  • basic: Un semplice approccio avido che seleziona lo scambio in grado di ridurre al minimo la distanza immediata dal cancello successivo.
  • decay (impostazione predefinita): assegna dinamicamente un peso ai qubit in base all'attività recente, scoraggiando lo scambio ripetuto degli stessi qubit.
  • lookahead: Valuta i costi di instradamento futuri analizzando in anticipo i gate successivi, individuando eventualmente sequenze di scambio più vantaggiose.

Per utilizzare un'euristica personalizzata, creare un SabreSwap pass e collegarlo a SabreLayout tramite il routing_pass parametro.

SabreSwap Al confronto viene aggiunto un quarto gestore di passaggi: pm_star_hw, che mantiene le impostazioni predefinite SabreLayout ma aggiunge StarPreRouting alla fase di inizializzazione. A questa scala (100 qubit) la ricerca SABRE risulta più complessa, e la riscrittura da una struttura a stella a una catena lineare si rivela chiaramente vantaggiosa, poiché un processore Heron dispone di percorsi lineari sufficientemente lunghi da ospitare il circuito risultante.

Qui mettiamo a confronto tutte e tre le euristiche SABRE, oltre StarPreRouting a una simulazione su larga scala su un circuito GHZ da 100 qubit. Eseguiamo diverse prove di layout con semi diversi per le configurazioni SABRE, selezioniamo il circuito transpilato migliore da ciascuna di esse e li sottoponiamo tutti a test su hardware reale insieme al StarPreRouting risultato.

I passaggi da 1 a 4 raggruppati in un unico blocco di codice

Qui il flusso di lavoro completo viene messo insieme su scala più ampia. Quando si utilizza SabreSwap come per routing_pass SabreLayout, viene eseguita una sola prova di layout per ogni chiamata; pertanto, la seguente cella di codice esegue un ciclo sui valori di seed per esplorare lo spazio dei layout.

Utilizziamo lo stesso wrap_sabre helper definito nella Fase 2 su piccola scala (sopra) e aggiungiamo un helper analogo wrap_routing , poiché anche lo routing stage all'indice [1] è un ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) — sostituirlo semplicemente eliminerebbe, analogamente, la barriera protettiva e il _swap_condition gating.

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

Analisi

I grafici a dispersione mostrano una notevole variabilità tra i vari semi per tutte e tre le euristiche SABRE, il che sottolinea l’importanza di eseguire più prove di layout piuttosto che affidarsi a un’unica transpilazione. La StarPreRouting linea rimane sostanzialmente piatta per tutti i seed, poiché la riscrittura da una struttura a stella a una catena lineare è deterministica, data la struttura; l’instradamento SABRE a valle ha quindi pochissima libertà su una catena lineare, per cui il seed non ha quasi alcun effetto sulla profondità o sulla dimensione finale.

Dai risultati della transpilazione emerge che entrambe le decay euristiche e lookahead superano basic costantemente con un ampio margine. L'euristica basic , pur essendo veloce, utilizza una semplice strategia avida che spesso porta alla creazione di circuiti notevolmente più profondi. Per questo circuito GHZ a topologia a stella, lookahead tende a produrre la profondità di ricerca " 2Q " e il numero di gate più bassi tra le euristiche SABRE, poiché la sua funzione di costo orientata al futuro è particolarmente adatta ai circuiti con modelli di connettività a lungo raggio. StarPreRouting, tuttavia, supera di gran lunga tutte e tre le soluzioni: riscrivendo la stella in una catena lineare prima dell'instradamento, aggira completamente il problema della ricerca e fornisce un circuito che il resto del transpiler può mappare su un percorso lineare con un numero minimo di SWAP aggiuntivi.

Questo vantaggio si riflette direttamente sulla fedeltà dell'hardware. Una minore profondità dell’ 2Q e e un numero inferiore di gate non si traducono sempre, in modo diretto, in una maggiore fedeltà (anche i qubit fisici specifici utilizzati da un layout e la loro calibrazione in fase di esecuzione hanno la loro importanza), ma quando il divario di profondità è così ampio come quello tra SABRE e StarPreRouting qui, l’approccio “structure-aware” prevale in modo decisivo, poiché il circuito accumula una decoerenza di gran lunga inferiore e un numero di eventi di errore a due qubit molto più ridotto. Il grafico a barre della fedeltà mostra StarPreRouting che è nettamente superiore persino alla migliore euristica SABRE, mentre basic si colloca ben al di sotto degli altri perché i suoi circuiti, molto più complessi, accumulano il maggior numero di errori.

Punti chiave:

  • Tra gli algoritmi euristici SABRE, decay e lookahead risultano sostanzialmente migliori di basic per i circuiti non banali. Per i carichi di lavoro di produzione, è preferibile utilizzare una delle due opzioni.
  • L'euristica SABRE più adatta dipende dal circuito e dall'hardware utilizzati. Testare più euristiche con più semi è la strategia più affidabile.
  • Se vuoi esplorare un numero ancora maggiore di layout, aumenta swap_trials (e layout_trials quando non stai fissando un passaggio di instradamento personalizzato) anziché distribuire il lavoro su nodi remoti. I passaggi SABRE parallelizzano già le prove tra i thread locali, e il carico di lavoro per singola prova è talmente ridotto che il sovraccarico di distribuzione finisce in genere per prevalere su qualsiasi aumento di velocità.
  • Quando il circuito presenta una struttura speciale nota, l'applicazione di un passaggio che tenga conto della struttura, come StarPreRouting quello precedente a SABRE, può garantire un miglioramento di un ordine di grandezza che nessuna ottimizzazione di SABRE potrà eguagliare. Questo non sostituisce SABRE: StarPreRouting è utile solo quando il circuito contiene effettivamente sottocircuiti a stella e il backend presenta un percorso lineare sufficientemente lungo. Quando conosci la forma del tuo circuito, vale la pena controllare se ci sono corrispondenze nella libreria dei pass.

Passi successivi

Se questo lavoro ti è sembrato interessante, potrebbero interessarti anche i seguenti contenuti:

Suggerimenti

Sondaggio tutorial

Vi invitiamo a partecipare a questo breve sondaggio per fornire un feedback su questa esercitazione. Le vostre indicazioni ci aiuteranno a migliorare la nostra offerta di contenuti e l'esperienza degli utenti.

Collegamento al sondaggio

Questa pagina è stata utile?
Segnala un bug, un errore di battitura o richiedi contenuti su GitHub.