Vai al contenuto principale

Ottimizzazione della transpilazione con SABRE

Stima di utilizzo: 1 minuto su un processore Heron r2 (NOTA: Questa è solo una stima. Il tempo di esecuzione effettivo potrebbe variare.)

Obiettivi di apprendimento

Dopo aver completato questo tutorial, dovresti capire:

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

Prerequisiti

Ti suggeriamo di avere familiarità con i seguenti argomenti prima di seguire questo tutorial:

Contesto

La transpilazione converte i circuiti quantistici in forme compatibili con hardware quantistico specifico. Due fasi chiave sono la scelta di un layout dei qubit (mappatura dei qubit logici ai qubit fisici) e il routing dei gate (inserimento di gate SWAP affinché i gate multi-qubit rispettino la connettività del dispositivo).

SABRE (SWAP-Based Bidirectional heuristic search algorithm) ottimizza sia il layout che il routing. È particolarmente efficace per circuiti su larga scala (oltre 100 qubit) su dispositivi con mappe di accoppiamento complesse, come i processori IBM® Heron. SABRE minimizza i gate SWAP e riduce la profondità del circuito, migliorando la fedeltà di esecuzione. I recenti miglioramenti nell'algoritmo LightSABRE riducono ulteriormente i tempi di esecuzione e i conteggi dei gate.

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

Requisiti

Prima di iniziare questo tutorial, assicurati di avere installato quanto segue:

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

Configurazione

# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-aer qiskit-ibm-runtime
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}")
Using backend: ibm_kingston

Esempio su piccola scala con simulatore

In questa sezione, si usa un simulatore rumoroso basato sul modello di rumore del backend reale per dimostrare come diverse configurazioni di SabreLayout influenzino sia la qualità della transpilazione che la fedeltà di esecuzione. L'uso di qiskit_aer con un modello di rumore derivato dai dati di calibrazione hardware reali ti consente di testare la transpilazione senza consumare crediti hardware.

Fase 1: Mappare gli input classici a un problema quantistico

Costruiamo un circuito GHZ con topologia a stella con 15 qubit. Il primo qubit è l'hub, con gate CNOT che lo collegano direttamente a ogni altro qubit. Questa topologia crea un problema di layout impegnativo perché non si mappa banalmente alla mappa di accoppiamento del dispositivo.

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

ghz_star_topology.png

Quando conosci la struttura del circuito

SABRE è un algoritmo generico e non fa assunzioni sulla struttura del circuito. Per questo circuito GHZ con topologia a stella, un routing ottimale è in realtà noto: il pass StarPreRouting rileva i sotto-circuiti a stella e li riscrive in una catena lineare che si mappa direttamente su qualsiasi backend con un percorso lineare abbastanza lungo. Questo tutorial si concentra su SABRE perché funziona per circuiti arbitrari, ma se sai che il tuo circuito ha una struttura speciale chiara, applicare un pass specializzato come StarPreRouting prima del routing può superare 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: Ottimizzare il problema per l'esecuzione su hardware quantistico

Il preset pass manager predefinito con optimization_level=3 usa già SabreLayout, ma con impostazioni predefinite conservative. Per esplorare l'impatto di impostazioni più aggressive, quel pass viene sostituito con un SabreLayout personalizzato configurato per una ricerca più aggressiva, mentre tutti gli altri pass nello stadio di layout vengono lasciati invariati. Come punto di confronto separato, un quarto pass manager mantiene il SabreLayout predefinito ma aggiunge StarPreRouting allo stadio di init. StarPreRouting è un pass structure-aware che rileva i sotto-circuiti a stella e li riscrive in una catena lineare prima del routing.

Il flusso di lavoro è:

  1. Ispezionare il pass manager predefinito per vedere dove SabreLayout si trova all'interno dello stadio layout.
  2. Sostituire quel pass con un'istanza personalizzata di SabreLayout usando PassManager.replace(index, passes=...), e costruire la variante pm_star con pm.init += StarPreRouting().
  3. Eseguire tutti e quattro i pass manager e confrontare le metriche.

Le quattro configurazioni sono:

ConfigDescrizione
pm_1 (predefinito)Preset di livello 3 predefinito (SabreLayout con max_iterations=4, layout_trials=20, swap_trials=20)
pm_2SabreLayout personalizzato (max_iterations=4, layout_trials=200, swap_trials=200)
pm_3SabreLayout personalizzato (max_iterations=8, layout_trials=200, swap_trials=200)
pm_starPreset predefinito con StarPreRouting aggiunto allo stadio di init

Parametri chiave di SABRE:

  • layout_trials / swap_trials: Controllano quanti layout candidati e soluzioni di routing SABRE esplora. Aumentare il numero di tentativi significa che SABRE campiona uno spazio di ricerca più ampio, aumentando la possibilità di trovare una soluzione migliore.
  • max_iterations: Controlla quanti cicli di raffinamento del routing avanti-indietro SABRE esegue su ogni candidato. SABRE migliora iterativamente il layout imparando dal feedback del routing, quindi più iterazioni, migliori i miglioramenti.

Entrambi hanno il costo di un tempo di transpilazione più lungo, ma i circuiti risultanti sono più corti e usano meno gate, il che riduce direttamente la decoerenza e gli errori dei gate sull'hardware reale.

Fase 2a: Ispezionare il pass manager predefinito. Uno StagedPassManager è composto da stadi (init, layout, routing, translation, optimization, scheduling), ognuno dei quali è a sua volta un PassManager. Chiamare .draw() su uno stadio ne visualizza i pass come grafo in modo da vedere dove si trova SabreLayout.

# 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 of the previous code cell

Nel diagramma sopra, il pass SabreLayout che vogliamo personalizzare si trova all'interno del ConditionalController in posizione [2] dello stadio di layout. Quel controller fa due cose:

  • Condiziona SabreLayout in modo che venga eseguito solo quando VF2Layout a [1] non ha trovato una mappatura perfetta (altrimenti il layout perfetto di VF2 viene mantenuto).
  • Precede SabreLayout con un pass BarrierBeforeFinalMeasurements che protegge le misurazioni dal riordino durante il routing interno di SabreLayout.

Se semplicemente facciamo replace(index=2, passes=sl_2), entrambi i comportamenti vengono eliminati. Per mantenerli, riavvolgiamo il nostro SabreLayout personalizzato nello stesso ConditionalController (con la stessa condizione e la barriera protettiva) prima di sostituirlo.

Fase 2b: Costruire pass SabreLayout personalizzati e sostituire quello predefinito.

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 of the previous code cell

La posizione [2] è di nuovo un ConditionalController — identica nella forma a quella predefinita, ma il SabreLayout interno è quello personalizzato (con layout_trials=200, swap_trials=200, e max_iterations=8 per pm_3; pm_2 è identico tranne per max_iterations=4). La barriera protettiva e il gating _vf2_match_not_found sono preservati, quindi l'unica differenza tra pm_2/pm_3 e pm_1 è la configurazione di SABRE stessa. pm_star mantiene il SabreLayout predefinito e aggiunge solo StarPreRouting alla fine dello stadio di init.

Fase 2c: Eseguire ciascun pass manager e confrontare.

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}%")
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 pass manager modificati hanno prodotto circuiti con profondità 2Q inferiore rispetto al predefinito. Le configurazioni aggressive di SABRE (pm_2 e pm_3) scambiano un tempo di transpilazione più lungo per una ricerca più ampia, mentre pm_star sfrutta la struttura a stella del circuito e produce un risultato ancora più superficiale senza pagare alcun costo aggiuntivo di transpilazione. I guadagni esatti varieranno da esecuzione a esecuzione, ma la tendenza generale è coerente: più tentativi e iterazioni di SABRE consentono alla ricerca euristica di esplorare uno spazio più ampio, e i pass structure-aware come StarPreRouting possono aggirare del tutto quella ricerca quando la forma del circuito corrisponde.

Anche a questa piccola scala (15 qubit), il margine di miglioramento è sufficiente affinché tutti e tre gli approcci superino il predefinito. Con circuiti più grandi (oltre 100 qubit), lo spazio di ricerca cresce drasticamente e i vantaggi sia dei tentativi aumentati che dei pass structure-aware diventano molto più pronunciati, come mostrerà la sezione 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 of the previous code cell

Fase 3: Eseguire usando le primitive Qiskit

Eseguiamo ogni circuito transpilato 10 volte usando l'EstimatorV2 di Aer con un modello di rumore derivato dal backend reale. Poiché i risultati della simulazione rumorosa variano tra le esecuzioni, fare la media su più esecuzioni fornisce stime di fedeltà più affidabili e ci consente di quantificare l'incertezza statistica con 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}"
)
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

Poiché questo è un circuito piccolo, i valori di fedeltà risultano relativamente vicini tra tutte e quattro le configurazioni. I circuiti sono abbastanza corti da non penalizzare pesantemente nemmeno la versione meno ottimizzata con il rumore hardware. La fedeltà media segue in linea di massima la profondità 2Q: pm_3 e pm_star, i due circuiti più superficiali, raggiungono le fedeltà più alte e sono essenzialmente alla pari entro le loro barre di errore. pm_2 è un utile contro-esempio: sebbene la sua profondità 2Q sia inferiore a quella di pm_1, la sua fedeltà media risulta leggermente inferiore, il che ricorda che il legame profondità-fedeltà è statistico piuttosto che deterministico. Anche i qubit specifici selezionati da un layout e la calibrazione di quei qubit al momento dell'esecuzione contano.

Fase 4: Post-elaborare e restituire il risultato nel formato classico desiderato

Successivamente, tracceremo le correlazioni di entanglement Z0Zi\langle Z_0 Z_i \rangle in funzione della distanza tra i qubit, insieme alla correlazione media come singola metrica di fedeltà. In un caso ideale (senza rumore), tutte le correlazioni sarebbero 1. Con il rumore realistico, ogni gate aggiuntivo introduce errori e ogni passo temporale aggiuntivo consente la decoerenza, quindi un circuito transpilato con profondità inferiore e meno gate (specialmente gate 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 of the previous code cell

I risultati mostrano una chiara connessione tra la qualità della transpilazione e la fedeltà di esecuzione, con alcune utili avvertenze:

  • pm_1 (predefinito): Baseline. Con soli 20 tentativi e quattro iterazioni, SABRE ha poco margine per ottimizzare, risultando nel circuito più profondo tra quelli che usano solo SABRE.
  • pm_2 (più tentativi): Esplorare dieci volte più candidati trova un layout leggermente più superficiale, ma la fedeltà media è sostanzialmente piatta (e può persino scendere sotto la baseline nel rumore) perché il guadagno di profondità è piccolo a questa scala.
  • pm_3 (più tentativi + più iterazioni): Raddoppiare max_iterations a 8 dà a SABRE più cicli di raffinamento, producendo il circuito solo-SABRE più superficiale e la fedeltà media più alta nel confronto.
  • pm_star (predefinito + StarPreRouting): Aggiunge StarPreRouting allo stadio di init di un preset altrimenti predefinito. La riscrittura structure-aware collassa la stella in una catena lineare che il resto del transpiler mappa sul percorso lineare del dispositivo, producendo il circuito più superficiale in assoluto (leggermente meglio di pm_3) e pareggiando pm_3 sulla fedeltà entro le barre di errore. Lo fa con lo stesso tempo di transpilazione del predefinito, poiché la riscrittura è essenzialmente gratuita rispetto alla ricerca stocastica di SABRE.

Nota che aumentare max_iterations non ha sempre un impatto positivo. In questo caso ha aiutato significativamente, ma per altri circuiti o backend le iterazioni aggiuntive potrebbero non portare ulteriori miglioramenti, o potrebbero persino peggiorare leggermente le prestazioni a causa di una sovra-ottimizzazione di un minimo locale. In generale, dovresti aumentare layout_trials e swap_trials il più possibile entro il tuo budget di tempo, poiché più tentativi aumentano sempre la possibilità di trovare un layout migliore. Aumentare max_iterations vale la pena testare ma dovrebbe essere validato per il tuo caso d'uso specifico. I pass specializzati come StarPreRouting sono simili nello spirito ma più dipendenti dal circuito: aiutano solo quando il circuito contiene effettivamente la struttura che prendono di mira. Il guadagno è grande quando applicabile e zero altrimenti, ma costano essenzialmente nulla da provare.

Esempio su larga scala con hardware

Oltre a regolare il numero di tentativi, SABRE supporta la personalizzazione dell'euristica di routing. SABRE offre tre euristiche:

  • basic: Un approccio greedy semplice che seleziona lo swap che minimizza la distanza immediata al gate successivo.
  • decay (predefinito): Pondera dinamicamente i qubit in base all'attività recente, scoraggiando swap ripetuti sugli stessi qubit.
  • lookahead: Valuta i costi futuri del routing guardando avanti ai gate successivi, trovando potenzialmente sequenze di swap migliori.

Per usare un'euristica personalizzata, crea un pass SabreSwap e collegalo a SabreLayout tramite il parametro routing_pass.

Un quarto pass manager viene aggiunto al confronto: pm_star_hw, che mantiene le impostazioni predefinite di SabreLayout/SabreSwap ma aggiunge StarPreRouting allo stadio di init. A questa scala (100 qubit) la ricerca SABRE è più difficile, e la riscrittura da una stella a una catena lineare diventa un chiaro vantaggio perché un processore Heron ha percorsi lineari abbastanza lunghi da ospitare il circuito risultante.

Qui confrontiamo tutte e tre le euristiche SABRE più StarPreRouting su larga scala su un circuito GHZ a 100 qubit. Eseguiamo più tentativi di layout con semi diversi per le configurazioni SABRE, selezioniamo il circuito transpilato migliore da ognuna, e li sottomettiamo tutti all'hardware reale insieme al risultato di StarPreRouting.

Fasi 1-4 compresse in un unico blocco di codice

Qui il flusso di lavoro completo viene messo insieme a scala maggiore. Quando si usa SabreSwap come routing_pass per SabreLayout, viene eseguito un solo tentativo di layout per chiamata, quindi la seguente cella di codice itera sui semi per esplorare lo spazio di layout.

Usiamo lo stesso helper wrap_sabre definito nella Fase 2 su piccola scala (sopra), e aggiungiamo un analogo helper wrap_routing perché lo stadio routing all'indice [1] è anch'esso un ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) — sostituirlo direttamente farebbe cadere anche la barriera protettiva e il gating _swap_condition.

# -------------------------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']})"
)
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 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")
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 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 variabilità significativa tra i semi per tutte e tre le euristiche SABRE, il che sottolinea l'importanza di eseguire più tentativi di layout piuttosto che affidarsi a una singola transpilazione. La linea di StarPreRouting è essenzialmente piatta tra i semi perché la riscrittura da una stella a una catena lineare è deterministica data la struttura; il routing SABRE successivo ha quindi pochissima libertà su una catena lineare, quindi il seme ha quasi nessun effetto sulla profondità o dimensione finale.

Dai risultati della transpilazione, sia le euristiche decay che lookahead superano costantemente basic con un ampio margine. L'euristica basic, pur essendo veloce, usa una strategia greedy semplice che spesso porta a circuiti sostanzialmente più profondi. Per questo circuito GHZ con topologia a stella, lookahead tende a produrre la profondità 2Q e il numero di gate più bassi tra le euristiche SABRE, poiché la sua funzione di costo forward-looking è ben adatta a circuiti con pattern di connettività a lungo raggio. StarPreRouting, tuttavia, supera tutti e tre di un ampio margine: riscrivendo la stella in una catena lineare prima del routing, cortocircuita completamente il problema di ricerca e produce un circuito che il resto del transpiler può mappare su un percorso lineare con SWAP aggiuntivi minimi.

Questo vantaggio si trasferisce direttamente alla fedeltà hardware. Una profondità 2Q e un numero di gate inferiori non si traducono sempre uno-a-uno in una fedeltà più alta (anche i qubit fisici specifici usati da un layout e la loro calibrazione al momento dell'esecuzione contano), ma quando il divario di profondità è grande come quello tra SABRE e StarPreRouting qui, l'approccio structure-aware vince in modo decisivo perché il circuito accumula molto meno decoerenza e molti meno eventi di errore a due qubit. Il grafico a barre della fedeltà mostra StarPreRouting sostanzialmente avanti anche della migliore euristica SABRE, mentre basic è ben al di sotto degli altri perché i suoi circuiti molto più profondi accumulano il maggior numero di errori.

Punti chiave:

  • Tra le euristiche SABRE, decay e lookahead sono sostanzialmente migliori di basic per circuiti non banali. Preferisci una delle due per carichi di lavoro in produzione.
  • La migliore euristica SABRE dipende dal tuo circuito e dall'hardware. Testare più euristiche con più semi è la strategia più affidabile.
  • Se vuoi esplorare ancora più layout, aumenta swap_trials (e layout_trials quando non stai fissando un routing pass personalizzato) piuttosto che distribuire il lavoro su nodi remoti. I pass SABRE parallelizzano già i tentativi su thread locali, e il lavoro per tentativo è abbastanza piccolo da far sì che l'overhead di distribuzione tipicamente superi qualsiasi speedup.
  • Quando il circuito ha una struttura speciale nota, applicare un pass structure-aware come StarPreRouting prima di SABRE può offrire un miglioramento di ordini di grandezza che nessuna quantità di tuning SABRE potrà eguagliare. Questo non è un sostituto di SABRE: StarPreRouting aiuta solo quando il circuito contiene effettivamente sotto-circuiti a stella e il backend ha un percorso lineare abbastanza lungo. Vale la pena controllare la libreria dei pass per corrispondenze ogni volta che conosci la forma del tuo circuito.

Passi successivi

Se hai trovato interessante questo lavoro, potresti essere interessato al seguente materiale:

Consigli

Sondaggio del tutorial

Ti preghiamo di compilare questo breve sondaggio per fornire feedback su questo tutorial. Le tue opinioni ci aiuteranno a migliorare la nostra offerta di contenuti e l'esperienza utente.

Nota: Questo sondaggio è di IBM Quantum e riguarda il contenuto del tutorial (scritto da IBM). doQumentation fornisce il sito web, le traduzioni e l'esecuzione del codice — per feedback su questi, per favore apri una issue su GitHub.

Link to survey