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:
- Circuiti di transpile : panoramica sulla transpile in Qiskit
- Fasi del transpiler : fasi di layout e di instradamento
- Configurazione dei gestori di passaggi predefiniti : personalizzazione dei livelli di ottimizzazione
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 tra coppie di qubit.
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:
- Esamina il gestore dei passaggi predefinito per vedere dove
SabreLayoutsi trova all’interno dellolayoutstage. - Sostituisci quel pass con un'istanza personalizzata
SabreLayoututilizzandoPassManager.replace(index, passes=...), e crea lapm_starvariante conpm.init += StarPreRouting(). - 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_2 | Personalizzato SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200) |
pm_3 | Personalizzato SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200) |
pm_star | Preimpostazione 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:
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
SabreLayoutmodo tale da attivarsi solo quandoVF2Layout[ non] è stato possibile trovare una mappatura perfetta (altrimenti viene mantenuta la disposizione perfetta VF2 ). - È preceduto
SabreLayoutda unBarrierBeforeFinalMeasurementspassaggio 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:
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:
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 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:
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) : Raddoppiandomax_iterationsil 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 ) : aggiungeStarPreRoutingalla 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 dipm_3) e raggiungendopm_3la 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:
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:
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,
decayelookaheadrisultano sostanzialmente migliori dibasicper 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(elayout_trialsquando 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
StarPreRoutingquello 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:
SabreLayoutRiferimento API : documentazione completa sui parametri- Documento SABRE : l'algoritmo SABRE originale per il layout e il routing
- LightSABRE articolo : i miglioramenti algoritmici alla base dell'attuale implementazione di SABRE in Qiskit
- Scrivi un passaggio di transpiler personalizzato : crea la tua logica di transpilazione
- Plugin di transpiler : estendere la pipeline di transpilazione di Qiskit con passaggi di terze parti
- Rappresentazione DAG : comprendere il grafo aciclico diretto utilizzato internamente dal transpiler
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.