Optymalizacja transpilacji z SABRE
Szacowany czas użycia: 1 minuta na procesorze Heron r2 (UWAGA: To jedynie szacunek. Rzeczywisty czas działania może się różnić.)
Wyniki uczenia
Po przejściu tego samouczka powinieneś rozumieć:
- Jak konfigurować parametry SABRE (
layout_trials,swap_trials,max_iterations), aby poprawić jakość transpilacji - Kompromisy między czasem transpilacji a jakością obwodu (głębokością i liczbą bramek)
- Jak dostosowywać heurystykę trasowania SABRE (
basic,decay,lookahead) i porównywać ich wydajność na sprzęcie
Wymagania wstępne
Sugerujemy zapoznanie się z następującymi tematami przed przystąpieniem do tego samouczka:
- Transpilacja obwodów: przegląd transpilacji w Qiskit
- Etapy transpilatora: etapy układu i trasowania
- Konfigurowanie gotowych menedżerów przejść: dostosowywanie poziomów optymalizacji
Kontekst
Transpilacja konwertuje obwody kwantowe do postaci zgodnych z konkretnym sprzętem kwantowym. Dwa kluczowe etapy to wybór układu qubitów (mapowanie logicznych qubitów na fizyczne qubity) oraz trasowanie bramek (wstawianie bramek SWAP, aby bramki wielokubitowe respektowały łączność urządzenia).
SABRE (SWAP-Based Bidirectional heuristic search algorithm) optymalizuje zarówno układ, jak i trasowanie. Jest szczególnie skuteczny dla dużoskalowych obwodów (100+ qubitów) na urządzeniach ze złożonymi mapami sprzężeń, jak procesory IBM® Heron. SABRE minimalizuje bramki SWAP i zmniejsza głębokość obwodu, poprawiając wierność wykonania. Najnowsze ulepszenia w algorytmie LightSABRE jeszcze bardziej skracają czasy działania i zmniejszają liczbę bramek.
W tym samouczku najpierw skonfigurujesz SabreLayout z różnymi parametrami, aby zoptymalizować mały obwód GHZ i zaobserwować wpływ na wierność wykonania. Następnie porównasz heurystyki trasowania SABRE w skali na prawdziwym sprzęcie.
Wymagania
Przed rozpoczęciem tego samouczka upewnij się, że masz zainstalowane:
- Qiskit SDK v2.0 lub nowszy, z obsługą wizualizacji
- Qiskit Runtime v0.22 lub nowszy (
pip install qiskit-ibm-runtime) - Qiskit Aer (
pip install qiskit-aer)
Konfiguracja
# 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
Przykład z symulatorem w małej skali
W tej sekcji używany jest zaszumiony symulator oparty na modelu szumu prawdziwego backendu, aby zademonstrować, jak różne konfiguracje SabreLayout wpływają zarówno na jakość transpilacji, jak i wierność wykonania. Użycie qiskit_aer z modelem szumu opartym na rzeczywistych danych kalibracyjnych sprzętu pozwala przetestować transpilację bez zużywania kredytów sprzętowych.
Krok 1: Mapowanie klasycznych danych wejściowych na problem kwantowy
Konstruujemy obwód GHZ o topologii gwiazdowej z 15 qubitami. Pierwszy qubit jest centrum, z bramkami CNOT łączącymi go bezpośrednio z każdym pozostałym qubitem. Ta topologia stanowi trudny problem układu, ponieważ nie mapuje się trywialnie na mapę sprzężeń urządzenia.
Definiujemy także operatory ZZ do mierzenia korelacji splątania dla par qubitów.

SABRE jest algorytmem ogólnego przeznaczenia i nie zakłada niczego o strukturze obwodu. Dla tego obwodu GHZ o topologii gwiazdowej optymalne trasowanie jest w rzeczywistości znane: przejście StarPreRouting wykrywa podobwody gwiazdowe i przepisuje je na łańcuch liniowy, który mapuje się bezpośrednio na dowolny backend z wystarczająco długą ścieżką liniową. Ten samouczek skupia się na SABRE, ponieważ działa dla dowolnych obwodów, ale jeśli wiesz, że twój obwód ma wyraźną specjalną strukturę, zastosowanie specjalizowanego przejścia, jak StarPreRouting, przed trasowaniem może przewyższyć dowolne przeszukiwanie heurystyczne.
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]
Krok 2: Optymalizacja problemu do wykonania na sprzęcie kwantowym
Domyślny gotowy menedżer przejść z optimization_level=3 już używa SabreLayout, ale z konserwatywnymi wartościami domyślnymi. Aby zbadać wpływ silniejszych ustawień, to przejście jest zastępowane niestandardowym SabreLayout skonfigurowanym do bardziej agresywnego przeszukiwania, podczas gdy wszystkie pozostałe przejścia na etapie układu pozostają niezmienione. Jako oddzielny punkt porównania, czwarty menedżer przejść zachowuje domyślny SabreLayout, ale dodaje StarPreRouting do etapu init. StarPreRouting to przejście świadome struktury, które wykrywa podobwody gwiazdowe i przepisuje je na łańcuch liniowy przed trasowaniem.
Przepływ pracy to:
- Zbadaj domyślny menedżer przejść, aby zobaczyć, gdzie
SabreLayoutsiedzi wewnątrz etapulayout. - Zastąp to przejście niestandardową instancją
SabreLayoutprzy użyciuPassManager.replace(index, passes=...)i zbuduj wariantpm_starzpm.init += StarPreRouting(). - Uruchom wszystkie cztery menedżery przejść i porównaj metryki.
Cztery konfiguracje to:
| Konfiguracja | Opis |
|---|---|
pm_1 (domyślny) | Domyślny gotowy poziom 3 (SabreLayout z max_iterations=4, layout_trials=20, swap_trials=20) |
pm_2 | Niestandardowy SabreLayout (max_iterations=4, layout_trials=200, swap_trials=200) |
pm_3 | Niestandardowy SabreLayout (max_iterations=8, layout_trials=200, swap_trials=200) |
pm_star | Domyślny gotowy menedżer z StarPreRouting dodanym do etapu init |
Kluczowe parametry SABRE:
layout_trials/swap_trials: Kontrolują, ile kandydujących układów i rozwiązań trasowania SABRE eksploruje. Zwiększenie liczby prób oznacza, że SABRE próbkuje szerszą przestrzeń poszukiwań, zwiększając szansę znalezienia lepszego rozwiązania.max_iterations: Kontroluje, ile cykli udoskonalania trasowania do przodu i do tyłu SABRE wykonuje dla każdego kandydata. SABRE iteracyjnie poprawia układ, ucząc się z informacji zwrotnej trasowania, więc im więcej iteracji, tym lepsze ulepszenia.
Oba wiążą się z kosztem dłuższego czasu transpilacji, ale wynikowe obwody są krótsze i używają mniej bramek, co bezpośrednio zmniejsza dekoherencję i błędy bramek na prawdziwym sprzęcie.
Krok 2a: Zbadaj domyślny menedżer przejść. StagedPassManager składa się z etapów (init, layout, routing, translation, optimization, scheduling), każdy sam będący PassManager. Wywołanie .draw() na etapie renderuje jego przejścia jako graf, więc możemy zobaczyć, gdzie znajduje się 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()

Na powyższym diagramie przejście SabreLayout, które chcemy dostosować, siedzi wewnątrz ConditionalController na pozycji [2] etapu layout. Ten kontroler robi dwie rzeczy:
- Blokuje
SabreLayout, żeby uruchamiało się tylko gdyVF2Layoutna [1] nie znalazło doskonałego mapowania (w przeciwnym razie doskonały układ VF2 jest zachowywany). - Poprzedza
SabreLayoutprzejściemBarrierBeforeFinalMeasurements, które chroni pomiary przed zmianą kolejności podczas wewnętrznego trasowania SabreLayout.
Jeśli po prostu użyjemy replace(index=2, passes=sl_2), oba zachowania zostaną utracone. Aby je zachować, opakujemy nasz niestandardowy SabreLayout w ten sam ConditionalController (z tym samym warunkiem i ochronną barierą) przed jego podmianą.
Krok 2b: Zbuduj niestandardowe przejścia SabreLayout i zastąp domyślne.
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()

Pozycja [2] to znowu ConditionalController — identyczny w kształcie do domyślnego, ale wewnętrzny SabreLayout to nasz niestandardowy (z layout_trials=200, swap_trials=200 i max_iterations=8 dla pm_3; pm_2 jest identyczny poza max_iterations=4). Ochronna bariera i blokowanie _vf2_match_not_found są zachowane, więc jedyną różnicą między pm_2/pm_3 a pm_1 jest sama konfiguracja SABRE. pm_star zachowuje domyślny SabreLayout i jedynie dodaje StarPreRouting na końcu etapu init.
Krok 2c: Uruchom każdy menedżer przejść i porównaj.
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%
Wszystkie trzy zmodyfikowane menedżery przejść wygenerowały obwody o niższej głębokości 2Q niż domyślny. Agresywne konfiguracje SABRE (pm_2 i pm_3) wymieniają dłuższy czas transpilacji na szersze przeszukiwanie, podczas gdy pm_star wykorzystuje gwiazdową strukturę obwodu i produkuje jeszcze płytszy wynik bez żadnych dodatkowych kosztów transpilacji. Dokładne korzyści będą się różnić między uruchomieniami, ale ogólny trend jest spójny: więcej prób i iteracji SABRE pozwala heurystyce przeszukiwać szerszą przestrzeń, a przejścia świadome struktury, jak StarPreRouting, mogą całkowicie ominąć to przeszukiwanie, gdy kształt obwodu pasuje.
Nawet w tej małej skali (15 qubitów), przestrzeń do poprawy jest wystarczająca, że wszystkie trzy podejścia bijią domyślny. W przypadku większych obwodów (100+ qubitów) przestrzeń poszukiwań dramatycznie rośnie i korzyści zarówno ze zwiększonej liczby prób, jak i przejść świadomych struktury stają się znacznie bardziej wyraźne, co pokaże sekcja o dużej skali.
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()

Krok 3: Wykonanie przy użyciu prymitywów Qiskit
Każdy przetranspilowany obwód uruchamiamy 10 razy przy użyciu EstimatorV2 z Aer z modelem szumu opartym na prawdziwym backendzie. Ponieważ wyniki zaszumionej symulacji różnią się między uruchomieniami, uśrednianie wielu uruchomień daje bardziej niezawodne szacunki wierności i pozwala kwantyfikować niepewność statystyczną za pomocą słupków błędów.
# 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
Ponieważ to jest mały obwód, wartości wierności we wszystkich czterech konfiguracjach są względnie podobne. Obwody są wystarczająco krótkie, że szum sprzętu nie mocno penalizuje nawet najmniej zoptymalizowaną wersję. Średnia wierność ogólnie śledzi głębokość 2Q: pm_3 i pm_star, dwa najpłytsze obwody, osiągają najwyższe wierności i są zasadniczo remisem w granicach słupków błędów. pm_2 jest użytecznym przykładem przeciwnym: mimo że jego głębokość 2Q jest niższa niż pm_1, jego średnia wierność wychodzi marginalnie niżej, co przypomina, że zależność między głębokością a wiernością jest statystyczna, a nie deterministyczna. Konkretne fizyczne qubity wybrany przez układ i kalibracja tych qubitów w czasie uruchomienia też mają znaczenie.
Krok 4: Post-przetwarzanie i zwracanie wyników w pożądanym formacie klasycznym
Następnie narysujemy korelacje splątania jako funkcję odległości qubitów, wraz ze średnią korelacją jako pojedynczą metryką wierności. W idealnym przypadku (bez szumu) wszystkie korelacje wynosiłyby 1. Przy realistycznym szumie każda dodatkowa bramka wprowadza błąd, a każdy dodatkowy krok czasowy umożliwia dekoherencję, więc przetranspilowany obwód o mniejszej głębokości i mniejszej liczbie bramek (zwłaszcza dwuqubitowych) powinien lepiej zachowywać splątanie.
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()

Wyniki pokazują wyraźny związek między jakością transpilacji a wiernością wykonania, z kilkoma użytecznymi zastrzeżeniami:
pm_1(domyślny): Punkt odniesienia. Mając tylko 20 prób i cztery iteracje, SABRE ma ograniczone możliwości optymalizacji, co skutkuje najgłębszym z obwodów tylko-SABRE.pm_2(więcej prób): Eksplorowanie dziesięć razy więcej kandydatów znajduje nieco płytszy układ, ale średnia wierność jest w przybliżeniu płaska (i może nawet spaść poniżej punktu odniesienia w granicach szumu), ponieważ zysk głębokości jest mały w tej skali.pm_3(więcej prób + więcej iteracji): Podwojeniemax_iterationsdo 8 daje SABRE więcej cykli udoskonalania, produkując najpłytszy obwód tylko-SABRE i najwyższą średnią wierność w porównaniu.pm_star(domyślny + StarPreRouting): DodajeStarPreRoutingdo etapu init w inaczej domyślnym gotowym menedżerze. Przepisanie świadome struktury zwija gwiazdę w łańcuch liniowy, który reszta transpilatora mapuje na liniową ścieżkę urządzenia, produkując najpłytszy obwód ogółem (nieco lepszy niżpm_3) i dorównującpm_3pod względem wierności w granicach słupków błędów. Robi to z tym samym czasem transpilacji co domyślny, ponieważ przepisanie jest zasadniczo darmowe w porównaniu z stochastycznym przeszukiwaniem SABRE.
Zauważ, że zwiększanie max_iterations nie zawsze ma pozytywny wpływ. W tym przypadku pomogło znacząco, ale dla innych obwodów lub backendów dodatkowe iteracje mogą nie przynosić dalszej poprawy, a nawet mogą nieco pogorszyć wydajność z powodu nadmiernej optymalizacji lokalnego minimum. Ogólnie powinieneś zwiększać layout_trials i swap_trials na tyle, na ile pozwala twój budżet czasowy, ponieważ więcej prób zawsze zwiększa szansę znalezienia lepszego układu. Zwiększanie max_iterations warto przetestować, ale powinno być walidowane dla twojego konkretnego przypadku użycia. Specjalizowane przejścia, jak StarPreRouting, są podobne w duchu, ale bardziej zależne od obwodu: pomagają tylko gdy obwód faktycznie zawiera strukturę, na którą są ukierunkowane. Zysk jest duży gdy ma zastosowanie i zerowy w przeciwnym razie, ale ich wypróbowanie nic praktycznie nie kosztuje.
Przykład sprzętowy w dużej skali
Oprócz dostosowywania liczby prób, SABRE obsługuje dostosowywanie heurystyki trasowania. SABRE oferuje trzy heurystyki:
basic: Proste podejście zachłanne, które wybiera zamianę minimalizującą natychmiastową odległość do następnej bramki.decay(domyślna): Dynamicznie waży qubity na podstawie ostatniej aktywności, zniechęcając do powtarzanych zamian na tych samych qubitach.lookahead: Ocenia przyszłe koszty trasowania, przeglądając nadchodzące bramki, potencjalnie znajdując lepsze sekwencje zamian.
Aby użyć niestandardowej heurystyki, utwórz przejście SabreSwap i połącz je z SabreLayout za pomocą parametru routing_pass.
Czwarty menedżer przejść zostaje dodany do porównania: pm_star_hw, który zachowuje domyślne ustawienia SabreLayout/SabreSwap, ale dodaje StarPreRouting do etapu init. W tej skali (100 qubitów) przeszukiwanie SABRE jest trudniejsze, a przepisanie ze gwiazdy na łańcuch liniowy staje się wyraźną przewagą, ponieważ procesor Heron ma wystarczająco długie ścieżki liniowe, aby pomieścić wynikowy obwód.
Tutaj porównujemy wszystkie trzy heurystyki SABRE plus StarPreRouting w skali na 100-kubitowym obwodzie GHZ. Uruchamiamy wiele prób układu z różnymi ziarenkami dla konfiguracji SABRE, wybieramy najlepszy przetranspilowany obwód z każdej i przesyłamy je wszystkie na prawdziwy sprzęt obok wyniku StarPreRouting.
Kroki 1-4 zebrane w jednym bloku kodu
Tutaj pełny przepływ pracy jest złożony w większej skali. Gdy SabreSwap jest używany jako routing_pass dla SabreLayout, wykonywana jest tylko jedna próba układu na wywołanie, więc poniższy blok kodu iteruje po ziarnach, aby eksplorować przestrzeń układu.
Używamy tego samego pomocnika wrap_sabre zdefiniowanego w małoskalowym kroku 2 (powyżej) i dodajemy analogiczny pomocnik wrap_routing, ponieważ etap routing na indeksie [1] to również ConditionalController([BarrierBeforeFinalMeasurements, routing_pass], ...) — zamiana go bez opakowania podobnie usunęłaby ochronną barierę i blokowanie _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']})"
)

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}")

Mean fidelity:
basic: 0.0344
decay: 0.1298
lookahead: 0.1857
StarPreRouting: 0.3295
Analiza
Wykresy rozproszenia pokazują znaczną zmienność między ziarenkami dla wszystkich trzech heurystyk SABRE, co podkreśla wagę uruchamiania wielu prób układu zamiast polegania na jednej transpilacji. Linia StarPreRouting jest zasadniczo płaska między ziarenkami, ponieważ przepisanie ze gwiazdy na łańcuch liniowy jest deterministyczne ze względu na strukturę; dalsze trasowanie SABRE ma wtedy bardzo małą swobodę na łańcuchu liniowym, więc ziarno prawie nie wpływa na końcową głębokość ani rozmiar.
Z wyników transpilacji wynika, że zarówno heurystyki decay, jak i lookahead konsekwentnie przewyższają basic ze znacznym marginesem. Heurystyka basic, choć szybka, używa prostej strategii zachłannej, która często prowadzi do znacznie głębszych obwodów. Dla tego obwodu GHZ o topologii gwiazdowej, lookahead tendencyjnie produkuje najniższą głębokość 2Q i liczbę bramek spośród heurystyk SABRE, ponieważ jej funkcja kosztów wybiegająca w przyszłość jest dobrze dopasowana do obwodów o wzorcach łączności dalekiego zasięgu. StarPreRouting jednak znacznie przewyższa wszystkie trzy ze znaczącym marginesem: przepisując gwiazdę w łańcuch liniowy przed trasowaniem, skraca problem przeszukiwania i dostarcza obwód, który reszta transpilatora może zmapować na ścieżkę liniową z minimalną liczbą dodatkowych SWAP.
Ta przewaga bezpośrednio przekłada się na wierność sprzętową. Niższa głębokość 2Q i mniejsza liczba bramek nie zawsze przekładają się jeden-do-jednego na wyższą wierność (konkretne fizyczne qubity, których używa układ, i ich kalibracja w czasie uruchomienia też mają znaczenie), ale gdy różnica głębokości jest tak duża jak ta między SABRE a StarPreRouting, podejście świadome struktury wygrywa zdecydowanie, ponieważ obwód akumuluje znacznie mniej dekoherencji i znacznie mniej zdarzeń błędów dwuqubitowych. Wykres słupkowy wierności pokazuje StarPreRouting znacznie przed nawet najlepszą heurystyką SABRE, podczas gdy basic leży wyraźnie poniżej reszty, ponieważ jego znacznie głębsze obwody akumulują największy błąd.
Kluczowe wnioski:
- Spośród heurystyk SABRE,
decayilookaheadsą znacznie lepsze odbasicdla nietrywialnych obwodów. Preferuj jedną z tych dwóch dla obciążeń produkcyjnych. - Najlepsza heurystyka SABRE zależy od twojego obwodu i sprzętu. Testowanie wielu heurystyk z wieloma ziarenkami to najbardziej niezawodna strategia.
- Jeśli chcesz eksplorować jeszcze więcej układów, zwiększaj
swap_trials(ilayout_trials, gdy nie przypinasz niestandardowego przejścia trasowania) zamiast rozdzielać pracę na zdalne węzły. Przejścia SABRE już zrównoleglają próby na lokalnych wątkach, a praca na próbę jest wystarczająco mała, że narzut dystrybucji zazwyczaj dominuje nad jakimkolwiek przyspieszeniem. - Gdy obwód ma znaną specjalną strukturę, zastosowanie przejścia świadomego struktury, jak
StarPreRouting, przed SABRE może dostarczyć poprawę rzędu wielkości, której żadna ilość dostrajania SABRE nie dorówna. To nie jest zamiennik dla SABRE:StarPreRoutingpomaga tylko gdy obwód faktycznie zawiera podobwody gwiazdowe i backend ma wystarczająco długą ścieżkę liniową. Warto sprawdzić bibliotekę przejść pod kątem dopasowań, gdy znasz kształt swojego obwodu.
Następne kroki
Jeśli ta praca okazała się interesująca, możesz zainteresować się następującymi materiałami:
- Dokumentacja API
SabreLayout: pełna dokumentacja parametrów - Artykuł SABRE: oryginalny algorytm SABRE dla układu i trasowania
- Artykuł LightSABRE: ulepszenia algorytmu zasilające bieżącą implementację SABRE w Qiskit
- Pisanie niestandardowego przejścia transpilatora: buduj własną logikę transpilacji
- Wtyczki transpilatora: rozszerzaj potok transpilacji Qiskit o przejścia stron trzecich
- Reprezentacja DAG: poznaj skierowany graf acykliczny używany wewnętrznie przez transpilator
Ankieta dotycząca samouczka
Prosimy o wypełnienie krótkiej ankiety, aby przekazać nam opinię na temat tego samouczka. Twoje spostrzeżenia pomogą nam ulepszyć ofertę treści i doświadczenie użytkownika.
Uwaga: Ta ankieta jest prowadzona przez IBM Quantum i dotyczy treści samouczka (napisanej przez IBM). doQumentation udostępnia witrynę, tłumaczenia i wykonanie kodu — w celu przekazania opinii na ich temat, prosimy otworzyć zgłoszenie na GitHub.